rails_error_dashboard 0.14.4 → 0.15.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -5
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
  5. data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
  6. data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
  7. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
  8. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
  9. data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
  10. data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
  11. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
  12. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
  13. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
  14. data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
  15. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
  16. data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
  17. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
  18. data/app/models/rails_error_dashboard/error_log.rb +43 -12
  19. data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +11 -1
  21. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
  22. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
  23. data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
  24. data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
  25. data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
  26. data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
  27. data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
  28. data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
  29. data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
  30. data/config/locales/de.yml +68 -5
  31. data/config/locales/en.yml +78 -4
  32. data/config/locales/es.yml +68 -5
  33. data/config/locales/fr.yml +68 -5
  34. data/config/locales/it.yml +68 -5
  35. data/config/locales/ja.yml +68 -4
  36. data/config/locales/pl.yml +68 -5
  37. data/config/locales/pt-BR.yml +68 -5
  38. data/config/locales/ru.yml +68 -5
  39. data/config/locales/uk.yml +68 -5
  40. data/config/locales/zh-CN.yml +68 -4
  41. data/config/routes.rb +6 -0
  42. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
  43. data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
  44. data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
  45. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
  46. data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
  47. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
  48. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
  49. data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
  50. data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
  51. data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
  52. data/lib/rails_error_dashboard/configuration.rb +42 -1
  53. data/lib/rails_error_dashboard/engine.rb +42 -1
  54. data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
  55. data/lib/rails_error_dashboard/plugin.rb +19 -3
  56. data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
  57. data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
  58. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
  59. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
  60. data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
  61. data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
  62. data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
  63. data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
  64. data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
  65. data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
  66. data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
  67. data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
  68. data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
  69. data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
  70. data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
  71. data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
  72. data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
  73. data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
  74. data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
  75. data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
  76. data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
  77. data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
  78. data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
  79. data/lib/rails_error_dashboard/version.rb +1 -1
  80. data/lib/rails_error_dashboard.rb +12 -0
  81. metadata +21 -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: e4e3190d52a36d5c36abb07855ac5090194c0e7d3e426f38565724d60c4d54c2
4
+ data.tar.gz: 22c63cf4f49ee2f97b6898b3ab19e81bdf45f5c382a29c6c771c5d462d760dec
5
5
  SHA512:
6
- metadata.gz: 9901f9f6fcc64cf53f71cdf43545e2314fe410638eafb414619159d9955763e8251007b13aa5d02e3967ebfbe4b87fb6dff3700694d27031d67ed39c8281e8ac
7
- data.tar.gz: d039b6f3796fad5884ae83bbe374671d5d2f90137747c0f404fd917cbcf8088f3b4101caf5c9b77524562b4e4c2c2ca3581d99abadfc16797d9bd35122c83afc
6
+ metadata.gz: fd736e679c40f293b58ad44b47f3215d001e45cd13c322d18f4033d178c8bc91242c018ccff8b676797d3282e1aa321e2ab15cc96050eabdd8bb3df42f6b7f63
7
+ data.tar.gz: '0696afc78d045942b741eaa166054455bd36b2962fb097cd67b7db8817c749243e3c70de3737c2e04aedd4b7cb52cd18ffb4d360a0b1d4805ff69e1ca4059716'
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
 
@@ -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
 
@@ -721,7 +728,7 @@ Users can also switch language per-session from the picker in the dashboard navb
721
728
 
722
729
  **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
730
 
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.
731
+ 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
732
 
726
733
  [Translations guide →](docs/guides/TRANSLATIONS.md) — how the system works, how to correct a string, and how to add a locale.
727
734
 
@@ -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
@@ -583,6 +583,52 @@ module RailsErrorDashboard
583
583
  @pagy, @events = pagy(:offset, all_events, limit: per_page_param)
584
584
  end
585
585
 
586
+ def missing_translations
587
+ unless RailsErrorDashboard.configuration.enable_missing_translation_tracking
588
+ flash[:alert] = feature_disabled_message("missing_translations", options: "enable_missing_translation_tracking = true", set: true)
589
+ redirect_to errors_path(**app_context_params)
590
+ return
591
+ end
592
+
593
+ days = days_param(default: 30)
594
+ @days = days
595
+ result = Queries::MissingTranslationSummary.call(days, application_id: @current_application_id)
596
+ all_entries = result[:entries]
597
+
598
+ # Summary stats (computed before pagination). The list is one row per
599
+ # (locale, key), so the badge and pagination count rows; the tile
600
+ # counts distinct keys, which is what "how many keys are missing" means.
601
+ @entry_count = all_entries.size
602
+ @unique_keys = all_entries.map { |e| e[:key] }.uniq.size
603
+ @total_misses = all_entries.sum { |e| e[:count] }
604
+ @locale_count = result[:locales].size
605
+ # Misses the tracker's bounded buffer dropped; shown so the total above
606
+ # is never silently understated.
607
+ @overflow_count = result[:overflow_count].to_i
608
+
609
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
610
+ end
611
+
612
+ def boot_errors
613
+ unless RailsErrorDashboard.configuration.enable_crash_capture
614
+ flash[:alert] = feature_disabled_message("boot_errors", options: "enable_crash_capture = true", set: true)
615
+ redirect_to errors_path(**app_context_params)
616
+ return
617
+ end
618
+
619
+ days = days_param(default: 30)
620
+ @days = days
621
+ result = Queries::BootErrors.call(days, application_id: @current_application_id)
622
+ all_entries = result[:entries]
623
+
624
+ # Summary stats (computed before pagination)
625
+ @entry_count = all_entries.size
626
+ @unresolved_count = result[:unresolved_count]
627
+ @total_occurrences = all_entries.sum { |e| e[:count] }
628
+
629
+ @pagy, @entries = pagy(:offset, all_entries, limit: per_page_param)
630
+ end
631
+
586
632
  def actioncable_health_summary
587
633
  unless RailsErrorDashboard.configuration.enable_actioncable_tracking &&
588
634
  RailsErrorDashboard.configuration.enable_breadcrumbs
@@ -634,6 +680,9 @@ module RailsErrorDashboard
634
680
 
635
681
  days = days_param(default: 30)
636
682
  @days = days
683
+ # Live reachability of the configured service, shown above the
684
+ # historical breadcrumb stats. One existence check per page load.
685
+ @storage_probe = Services::ActiveStorageProbe.call
637
686
  result = Queries::ActiveStorageSummary.call(days, application_id: @current_application_id)
638
687
  all_services = result[:services]
639
688
 
@@ -719,7 +768,17 @@ module RailsErrorDashboard
719
768
  )
720
769
  exception.set_backtrace(caller)
721
770
 
722
- Commands::LogError.call(exception, { request: request, source: "dashboard.test_error" })
771
+ started_at = Time.current
772
+ error_log = Commands::LogError.call(exception, { request: request, source: "dashboard.test_error" })
773
+
774
+ # The capture path notifies on a first occurrence and then on the
775
+ # 10th, 50th... A second click within 24 hours is occurrence two of the
776
+ # same row, so it used to notify nobody, which reads as "notifications
777
+ # are broken". This button exists to prove delivery: if the capture
778
+ # path did not claim a notification in this request, send one now.
779
+ if error_log && !notified_since?(error_log, started_at)
780
+ Services::ErrorNotificationDispatcher.call(error_log)
781
+ end
723
782
 
724
783
  flash[:notice] = red_t("red.flash.test_error.logged")
725
784
  redirect_to errors_path(**app_context_params)
@@ -785,7 +844,14 @@ module RailsErrorDashboard
785
844
  end
786
845
 
787
846
  def filter_params
788
- params.permit(*FILTERABLE_PARAMS).to_h.symbolize_keys
847
+ scalar_params(FILTERABLE_PARAMS)
848
+ end
849
+
850
+ # True when NotificationThrottler claimed a notification for this error
851
+ # at or after +since+. The claim is an update_all, so the row is re-read.
852
+ def notified_since?(error_log, since)
853
+ stamp = error_log.class.where(id: error_log.id).pick(:last_notified_at)
854
+ stamp.present? && stamp >= since
789
855
  end
790
856
 
791
857
  # Coerce params[:days] into a sane integer in [1, 365]. Without clamping,
@@ -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
@@ -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
@@ -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
@@ -11,9 +11,10 @@ module RailsErrorDashboard
11
11
  module PlainChannelMessage
12
12
  private
13
13
 
14
- # Slack takes {text:}, Discord {content:}, custom webhooks a
15
- # machine-readable event. Email and PagerDuty are not used: neither has
16
- # a sensible shape for a message that is not an incident.
14
+ # Slack takes {text:}, Discord {content:}, Telegram a plain sendMessage,
15
+ # custom webhooks a machine-readable event. Email and PagerDuty are not
16
+ # used: neither has a sensible shape for a message that is not an
17
+ # incident.
17
18
  def deliver_plain_message(message, webhook_payload)
18
19
  config = RailsErrorDashboard.configuration
19
20
  delivered = false
@@ -28,15 +29,20 @@ module RailsErrorDashboard
28
29
  delivered = true
29
30
  end
30
31
 
32
+ if Services::TelegramDelivery.configured?(config)
33
+ post_telegram(message)
34
+ delivered = true
35
+ end
36
+
31
37
  if config.enable_webhook_notifications && config.webhook_urls.present?
32
- config.webhook_urls.each { |url| post_json(url, webhook_payload) }
38
+ config.webhook_urls.each { |url| post_webhook(url, webhook_payload) }
33
39
  delivered = true
34
40
  end
35
41
 
36
42
  # An email-only or PagerDuty-only deployment has no channel for this.
37
43
  # Say so where an operator can find it rather than dropping it silently.
38
44
  unless delivered
39
- Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord or webhook channel is enabled; message not sent: #{message}")
45
+ Rails.logger.warn("[RailsErrorDashboard] #{self.class.name.demodulize}: no Slack, Discord, Telegram or webhook channel is enabled; message not sent: #{message}")
40
46
  end
41
47
  end
42
48
 
@@ -49,6 +55,27 @@ module RailsErrorDashboard
49
55
  (defined?(Rails) && Rails.application.class.module_parent_name) || "Rails Application"
50
56
  end
51
57
 
58
+ # Custom webhooks go through WebhookDelivery so they are signed when
59
+ # webhook_signing_secret is set. Slack and Discord stay on post_json:
60
+ # their payloads are not ours to sign. Same per-URL rescue as post_json,
61
+ # so one unreachable receiver does not stop the next.
62
+ def post_webhook(url, payload)
63
+ Services::WebhookDelivery.post(url, payload)
64
+ rescue => e
65
+ Rails.logger.error("[RailsErrorDashboard] #{self.class.name.demodulize} post failed: #{e.message}")
66
+ end
67
+
68
+ # No parse_mode: the sentence is sent verbatim, so an app name with a
69
+ # "<" in it cannot break Telegram's HTML parser. The token sits in the
70
+ # request URL, hence the redaction before logging.
71
+ def post_telegram(message)
72
+ Services::TelegramDelivery.send_message(message)
73
+ rescue => e
74
+ Rails.logger.error(
75
+ "[RailsErrorDashboard] #{self.class.name.demodulize} Telegram post failed: #{Services::TelegramDelivery.redact(e.message)}"
76
+ )
77
+ end
78
+
52
79
  def post_json(url, payload)
53
80
  if defined?(HTTParty)
54
81
  HTTParty.post(url, body: payload.to_json,
@@ -59,7 +86,7 @@ module RailsErrorDashboard
59
86
  http.use_ssl = uri.scheme == "https"
60
87
  http.open_timeout = 5
61
88
  http.read_timeout = 10
62
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
89
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
63
90
  request.body = payload.to_json
64
91
  http.request(request)
65
92
  end
@@ -32,7 +32,7 @@ module RailsErrorDashboard
32
32
  http.use_ssl = uri.scheme == "https"
33
33
  http.open_timeout = 5
34
34
  http.read_timeout = 10
35
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
35
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
36
36
  request.body = payload.to_json
37
37
  http.request(request)
38
38
  end
@@ -44,7 +44,7 @@ module RailsErrorDashboard
44
44
  http.use_ssl = uri.scheme == "https"
45
45
  http.open_timeout = 5
46
46
  http.read_timeout = 10
47
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
47
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
48
48
  request.body = payload.to_json
49
49
  http.request(request)
50
50
  end
@@ -70,6 +70,7 @@ module RailsErrorDashboard
70
70
  # errors — clean them up BEFORE the early return below, which fires
71
71
  # whenever no error logs happen to be expired.
72
72
  cleanup_rack_attack_events(cutoff)
73
+ cleanup_missing_translations(cutoff)
73
74
  cleanup_storm_flush_batches(cutoff)
74
75
  cleanup_diagnostic_dumps(cutoff)
75
76
  cleanup_swallowed_exceptions(cutoff)
@@ -211,6 +212,31 @@ module RailsErrorDashboard
211
212
  )
212
213
  end
213
214
 
215
+ # Expire missing-translation rows not seen since the cutoff. A key that
216
+ # is still missing is still being seen, so it keeps its row (and its
217
+ # first_seen_at); only keys that stopped missing age out.
218
+ #
219
+ # Not gated on the feature flag: rows collected while tracking was on
220
+ # must still age out after it is turned off.
221
+ def cleanup_missing_translations(cutoff)
222
+ return unless MissingTranslation.table_exists?
223
+
224
+ deleted = 0
225
+ MissingTranslation.where("last_seen_at < ?", cutoff).in_batches(of: 1000) do |batch|
226
+ deleted += batch.delete_all
227
+ end
228
+
229
+ if deleted > 0
230
+ RailsErrorDashboard::Logger.info(
231
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} missing translations"
232
+ )
233
+ end
234
+ rescue => e
235
+ RailsErrorDashboard::Logger.debug(
236
+ "[RailsErrorDashboard] Missing translation retention cleanup failed: #{e.class} - #{e.message}"
237
+ )
238
+ end
239
+
214
240
  # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
215
241
  # failure here (e.g. table not yet migrated) never blocks error cleanup.
216
242
  def cleanup_rack_attack_events(cutoff)
@@ -12,11 +12,16 @@ module RailsErrorDashboard
12
12
  class ScheduledDigestJob < ApplicationJob
13
13
  queue_as :default
14
14
 
15
+ # @param period [String, Symbol, nil] "daily" or "weekly"; nil takes
16
+ # config.digest_frequency, which was shown on the Settings page but
17
+ # read by nothing until now.
15
18
  # @param locale [String, nil] resolved at enqueue time. nil for jobs
16
19
  # enqueued by a pre-Phase-4 version still draining from the queue.
17
- def perform(period: "daily", application_id: nil, locale: nil)
20
+ def perform(period: nil, application_id: nil, locale: nil)
18
21
  return unless RailsErrorDashboard.configuration.enable_scheduled_digests
19
22
 
23
+ period ||= RailsErrorDashboard.configuration.digest_frequency || :daily
24
+
20
25
  recipients = effective_recipients
21
26
  return if recipients.blank?
22
27
 
@@ -32,7 +32,7 @@ module RailsErrorDashboard
32
32
  http.open_timeout = 5 # 5 seconds to establish connection
33
33
  http.read_timeout = 10 # 10 seconds to read response
34
34
 
35
- request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
35
+ request = Net::HTTP::Post.new(uri.request_uri, { "Content-Type" => "application/json" })
36
36
  request.body = Services::SlackPayloadBuilder.call(error_log, locale: locale).to_json
37
37
 
38
38
  response = http.request(request)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Job to send error notifications to a Telegram chat via the Bot API
5
+ class TelegramErrorNotificationJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ # @param locale [String, nil] resolved at enqueue time.
9
+ def perform(error_log_id, locale = nil)
10
+ return unless Services::TelegramDelivery.credentials?
11
+
12
+ error_log = ErrorLog.find(error_log_id)
13
+ payload = Services::TelegramPayloadBuilder.call(error_log, locale: job_locale(locale))
14
+
15
+ Services::TelegramDelivery.post(payload)
16
+ rescue StandardError => e
17
+ # The bot token is in the request URL; an exception that quotes it must
18
+ # not reach the log as-is.
19
+ Rails.logger.error(
20
+ "[RailsErrorDashboard] Failed to send Telegram notification: #{Services::TelegramDelivery.redact(e.message)}"
21
+ )
22
+ Rails.logger.error(e.backtrace&.first(5)&.join("\n")) if e.backtrace
23
+ end
24
+ end
25
+ end
@@ -31,13 +31,12 @@ module RailsErrorDashboard
31
31
 
32
32
  def send_webhook(url, payload, error_log)
33
33
  headers = {
34
- "Content-Type" => "application/json",
35
- "User-Agent" => "RailsErrorDashboard/1.0",
36
34
  "X-Error-Dashboard-Event" => "error.created",
37
35
  "X-Error-Dashboard-ID" => error_log.id.to_s
38
36
  }
39
37
 
40
- response = post_json(url, payload, headers)
38
+ # Serialises, signs (when webhook_signing_secret is set) and posts.
39
+ response = Services::WebhookDelivery.post(url, payload, headers: headers)
41
40
 
42
41
  unless response_success?(response)
43
42
  Rails.logger.warn("[RailsErrorDashboard] Webhook failed for #{url}: #{response_code(response)}")
@@ -46,21 +45,6 @@ module RailsErrorDashboard
46
45
  Rails.logger.error("[RailsErrorDashboard] Webhook error for #{url}: #{e.message}")
47
46
  end
48
47
 
49
- def post_json(url, payload, headers)
50
- if defined?(HTTParty)
51
- HTTParty.post(url, body: payload.to_json, headers: headers, timeout: 10)
52
- else
53
- uri = URI(url)
54
- http = Net::HTTP.new(uri.host, uri.port)
55
- http.use_ssl = uri.scheme == "https"
56
- http.open_timeout = 5
57
- http.read_timeout = 10
58
- request = Net::HTTP::Post.new(uri.path, headers)
59
- request.body = payload.to_json
60
- http.request(request)
61
- end
62
- end
63
-
64
48
  def response_success?(response)
65
49
  if response.respond_to?(:success?)
66
50
  response.success?