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.
- checksums.yaml +4 -4
- data/README.md +12 -5
- data/app/controllers/rails_error_dashboard/application_controller.rb +28 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +68 -2
- data/app/controllers/rails_error_dashboard/health_controller.rb +38 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +2 -2
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +56 -2
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +20 -2
- data/app/jobs/rails_error_dashboard/cascade_detection_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/concerns/plain_channel_message.rb +33 -6
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +26 -0
- data/app/jobs/rails_error_dashboard/scheduled_digest_job.rb +6 -1
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/telegram_error_notification_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +2 -18
- data/app/models/rails_error_dashboard/error_log.rb +43 -12
- data/app/models/rails_error_dashboard/missing_translation.rb +43 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +11 -1
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +3 -9
- data/app/views/rails_error_dashboard/errors/activestorage_health_summary.html.erb +28 -0
- data/app/views/rails_error_dashboard/errors/boot_errors.html.erb +130 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/missing_translations.html.erb +143 -0
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +7 -4
- data/app/views/rails_error_dashboard/errors/settings.html.erb +19 -5
- data/config/locales/de.yml +68 -5
- data/config/locales/en.yml +78 -4
- data/config/locales/es.yml +68 -5
- data/config/locales/fr.yml +68 -5
- data/config/locales/it.yml +68 -5
- data/config/locales/ja.yml +68 -4
- data/config/locales/pl.yml +68 -5
- data/config/locales/pt-BR.yml +68 -5
- data/config/locales/ru.yml +68 -5
- data/config/locales/uk.yml +68 -5
- data/config/locales/zh-CN.yml +68 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +9 -4
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +27 -0
- data/lib/rails_error_dashboard/commands/assign_error.rb +24 -1
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +9 -1
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +103 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +1 -1
- data/lib/rails_error_dashboard/commands/resolve_error.rb +2 -15
- data/lib/rails_error_dashboard/commands/update_error_status.rb +7 -0
- data/lib/rails_error_dashboard/configuration.rb +42 -1
- data/lib/rails_error_dashboard/engine.rb +42 -1
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +7 -1
- data/lib/rails_error_dashboard/plugin.rb +19 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +61 -12
- data/lib/rails_error_dashboard/queries/boot_errors.rb +76 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +3 -11
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -16
- data/lib/rails_error_dashboard/queries/health_status.rb +191 -0
- data/lib/rails_error_dashboard/queries/missing_translation_summary.rb +82 -0
- data/lib/rails_error_dashboard/services/active_storage_probe.rb +91 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +13 -7
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +15 -0
- data/lib/rails_error_dashboard/services/baseline_alert_payload_builder.rb +30 -0
- data/lib/rails_error_dashboard/services/crash_capture.rb +85 -2
- data/lib/rails_error_dashboard/services/digest_builder.rb +9 -6
- data/lib/rails_error_dashboard/services/error_notification_dispatcher.rb +6 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +25 -9
- data/lib/rails_error_dashboard/services/missing_translation_handler.rb +152 -0
- data/lib/rails_error_dashboard/services/missing_translation_tracker.rb +316 -0
- data/lib/rails_error_dashboard/services/resolution_callbacks.rb +44 -0
- data/lib/rails_error_dashboard/services/severity_classifier.rb +52 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +23 -3
- data/lib/rails_error_dashboard/services/telegram_delivery.rb +134 -0
- data/lib/rails_error_dashboard/services/telegram_payload_builder.rb +105 -0
- data/lib/rails_error_dashboard/services/webhook_delivery.rb +63 -0
- data/lib/rails_error_dashboard/services/webhook_signer.rb +80 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +12 -0
- metadata +21 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4e3190d52a36d5c36abb07855ac5090194c0e7d3e426f38565724d60c4d54c2
|
|
4
|
+
data.tar.gz: 22c63cf4f49ee2f97b6898b3ab19e81bdf45f5c382a29c6c771c5d462d760dec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
231
|
-
# so that
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
#
|
|
156
|
-
|
|
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
|
-
|
|
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.
|
|
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:},
|
|
15
|
-
# machine-readable event. Email and PagerDuty are not
|
|
16
|
-
# a sensible shape for a message that is not an
|
|
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|
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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?
|