rails_error_dashboard 0.14.3 → 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 +45 -17
- 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 +10 -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 +24 -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 +72 -5
- data/config/locales/en.yml +83 -4
- data/config/locales/es.yml +72 -5
- data/config/locales/fr.yml +72 -5
- data/config/locales/it.yml +72 -5
- data/config/locales/ja.yml +72 -4
- data/config/locales/pl.yml +72 -5
- data/config/locales/pt-BR.yml +72 -5
- data/config/locales/ru.yml +72 -5
- data/config/locales/uk.yml +72 -5
- data/config/locales/zh-CN.yml +72 -4
- data/config/routes.rb +6 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +10 -2
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +9 -0
- data/db/migrate/20261010000001_create_missing_translations.rb +50 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +40 -38
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +31 -3
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +11 -2
- 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 +6 -3
- 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 +75 -2
- data/lib/rails_error_dashboard/engine.rb +52 -5
- data/lib/rails_error_dashboard/middleware/rate_limiter.rb +21 -43
- data/lib/rails_error_dashboard/plugin.rb +49 -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 +11 -17
- 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/exception_filter.rb +4 -1
- 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/solid_queue_config_check.rb +47 -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
- data/lib/tasks/error_dashboard.rake +43 -2
- metadata +21 -2
data/config/locales/zh-CN.yml
CHANGED
|
@@ -60,6 +60,8 @@ zh-CN:
|
|
|
60
60
|
llm: LLM
|
|
61
61
|
n_plus_one: N+1 查询
|
|
62
62
|
deprecations: Deprecations
|
|
63
|
+
missing_translations: "翻译"
|
|
64
|
+
boot_errors: "启动错误"
|
|
63
65
|
swallowed: 被吞异常
|
|
64
66
|
diagnostic_dumps: 诊断
|
|
65
67
|
storms: 风暴
|
|
@@ -85,6 +87,10 @@ zh-CN:
|
|
|
85
87
|
body_html: 你正在使用默认凭据(gandalf/youshallnotpass)。请设置环境变量 <code>ERROR_DASHBOARD_USER</code>
|
|
86
88
|
和 <code>ERROR_DASHBOARD_PASSWORD</code>,或在初始化文件中配置 <code>authenticate_with</code>。
|
|
87
89
|
dismiss: 本次会话内不再提示
|
|
90
|
+
solid_queue:
|
|
91
|
+
label: "Solid Queue 不会运行 RED 的任务。"
|
|
92
|
+
body_html: "在修复 <code>config/queue.yml</code> 之前,后台捕获的错误和通知会一直在队列中等待。运行 <code>bin/rails error_dashboard:verify</code> 查看修复方法,然后重启应用。"
|
|
93
|
+
guide: "RED 对 Solid Queue 的要求"
|
|
88
94
|
storm:
|
|
89
95
|
active_title: 错误风暴进行中
|
|
90
96
|
recent_title: 近期触发过风暴保护
|
|
@@ -749,6 +755,53 @@ zh-CN:
|
|
|
749
755
|
column_ratio: 比例
|
|
750
756
|
view_unrescued: 查看未被捕获、已进入 error_log 的 %{exception} 错误
|
|
751
757
|
footer_tip: 被吞异常先被抛出,随后被静默捕获,可能掩盖真正的缺陷。
|
|
758
|
+
missing_translations_page:
|
|
759
|
+
title: "缺失的翻译"
|
|
760
|
+
empty_title: "未记录到缺失的翻译"
|
|
761
|
+
empty_body: "最近 %{days} 天内未记录到缺失的翻译键。"
|
|
762
|
+
how_title: "缺失翻译的跟踪方式:"
|
|
763
|
+
how_enabled_html: "必须启用跟踪(%{option})"
|
|
764
|
+
how_hooked: "RED 会包装 I18n.exception_handler,并把调用交给应用原有的处理器;I18n 配置的其他部分不会改变"
|
|
765
|
+
how_recorded: "每一次到达处理器的查找——无论来自 I18n.t 还是视图助手 t()——都会按语言区域和键计数,并记录首次观测到的调用位置"
|
|
766
|
+
how_not_seen: "提供了 :default 的查找永远不会缺失;使用 raise_on_missing_translations 的查找会在任何处理器运行前抛出异常,因此两者都不会在此计数"
|
|
767
|
+
how_buffered: "缺失在内存中计数,并在请求或任务结束时写入,因此显示会有短暂延迟"
|
|
768
|
+
missing_keys: "缺失的键"
|
|
769
|
+
total_misses: "缺失总数"
|
|
770
|
+
locales_affected: "受影响的语言区域"
|
|
771
|
+
all_title: "缺失的翻译键"
|
|
772
|
+
column_key: "键"
|
|
773
|
+
column_locale: "语言区域"
|
|
774
|
+
column_source: "首次出现于"
|
|
775
|
+
column_misses: "缺失次数"
|
|
776
|
+
column_first_seen: "首次出现"
|
|
777
|
+
overflow_notice: "由于跟踪器的缓冲区(每线程 %{limit} 个键)已满,有 %{count} 次缺失无法归到具体的键。"
|
|
778
|
+
footer_tip: "把该键加入语言文件,或传入 :default。用户实际遇到的键排在最前。"
|
|
779
|
+
docs: "Rails i18n 指南"
|
|
780
|
+
|
|
781
|
+
boot_errors_page:
|
|
782
|
+
title: "启动错误"
|
|
783
|
+
empty_title: "未记录到启动错误"
|
|
784
|
+
empty_body: "最近 %{days} 天内没有进程在启动时崩溃。"
|
|
785
|
+
how_title: "启动错误的捕获方式:"
|
|
786
|
+
how_enabled_html: "必须启用崩溃捕获(%{option})"
|
|
787
|
+
how_hooked: "RED 在你的 initializer 运行之后、Rails 预加载之前注册 at_exit 钩子,因此即使应用从未完成启动,启动期间的崩溃也会被捕获"
|
|
788
|
+
how_captured_html: "文件定义了错误常量导致的 %{zeitwerk}、模型中的 %{syntax}、抛出异常的 initializer:任何在处理请求之前就终止进程的问题"
|
|
789
|
+
how_imported: "此时还没有数据库,所以崩溃先写入磁盘,并在下一次成功启动时导入"
|
|
790
|
+
how_never: "被 SIGKILL 或段错误终止的进程,任何工具的 at_exit 都看不到"
|
|
791
|
+
unresolved: "未解决"
|
|
792
|
+
boot_crashes: "启动崩溃"
|
|
793
|
+
failed_boots: "失败的启动"
|
|
794
|
+
all_title: "按错误分组的启动崩溃"
|
|
795
|
+
column_error: "错误"
|
|
796
|
+
column_file: "文件"
|
|
797
|
+
column_constant: "常量"
|
|
798
|
+
column_boots: "启动次数"
|
|
799
|
+
column_first_seen: "首次出现"
|
|
800
|
+
column_status: "状态"
|
|
801
|
+
status_open: "未处理"
|
|
802
|
+
status_resolved: "已解决"
|
|
803
|
+
footer_tip: "启动崩溃意味着进程从未处理过请求。请在下次部署前修复;该行会像其他错误一样被标记为已解决。"
|
|
804
|
+
|
|
752
805
|
rack_attack_page:
|
|
753
806
|
title: 限流事件
|
|
754
807
|
missing_title: 尚未安装 Rack Attack
|
|
@@ -963,7 +1016,7 @@ zh-CN:
|
|
|
963
1016
|
unable_to_detect: 无法检测
|
|
964
1017
|
no_user_model: 未检测到 User 模型
|
|
965
1018
|
unable_to_query_count: 无法查询数量
|
|
966
|
-
|
|
1019
|
+
retention_enforced_by_job: "由 RetentionCleanupJob 在您的调度器运行它时删除"
|
|
967
1020
|
keep_forever: 永久保留
|
|
968
1021
|
no_automatic_deletion: 不自动删除
|
|
969
1022
|
run_command: 执行:
|
|
@@ -971,7 +1024,7 @@ zh-CN:
|
|
|
971
1024
|
options:
|
|
972
1025
|
enable_middleware: 捕获未处理的异常
|
|
973
1026
|
enable_error_subscriber: 订阅 Rails 错误事件
|
|
974
|
-
retention_days:
|
|
1027
|
+
retention_days: "删除这么多天内未再出现的错误。由 RetentionCleanupJob 执行,gem 本身不会安排它运行"
|
|
975
1028
|
max_backtrace_lines: 堆栈跟踪深度上限
|
|
976
1029
|
sampling_rate: 错误捕获的采样比例
|
|
977
1030
|
application_name: 本应用的名称(未设置时自动检测)
|
|
@@ -985,7 +1038,7 @@ zh-CN:
|
|
|
985
1038
|
total_users_for_impact: 用于计算影响百分比(未设置时自动检测)
|
|
986
1039
|
async_logging: 在后台任务中处理
|
|
987
1040
|
async_adapter: 任务队列适配器
|
|
988
|
-
enable_rate_limiting:
|
|
1041
|
+
enable_rate_limiting: "按 IP 和路径限制仪表盘请求"
|
|
989
1042
|
rate_limit_per_minute: 每个 IP 每分钟的请求数
|
|
990
1043
|
enable_slack_notifications: 实时通知
|
|
991
1044
|
slack_webhook_url: Slack webhook 地址
|
|
@@ -994,10 +1047,15 @@ zh-CN:
|
|
|
994
1047
|
notification_email_from: 发件人地址
|
|
995
1048
|
enable_discord_notifications: 通过 Discord webhook 通知
|
|
996
1049
|
discord_webhook_url: Discord webhook 地址
|
|
1050
|
+
enable_telegram_notifications: "Telegram Bot API 通知"
|
|
1051
|
+
telegram_bot_token: "来自 @BotFather 的机器人令牌(设置环境变量 TELEGRAM_BOT_TOKEN)"
|
|
1052
|
+
telegram_chat_id: "机器人发送消息的聊天、群组或频道"
|
|
1053
|
+
telegram_api_base_url: "Bot API 服务器(仅在使用代理或自建服务器时更改)"
|
|
997
1054
|
enable_pagerduty_notifications: 严重错误升级
|
|
998
1055
|
pagerduty_integration_key: PagerDuty API 密钥
|
|
999
1056
|
enable_webhook_notifications: 通用 webhook 地址
|
|
1000
1057
|
webhook_urls: 自定义 webhook 地址
|
|
1058
|
+
webhook_signing_secret: 使用 HMAC-SHA256 签名发出的 webhook(设置环境变量 WEBHOOK_SIGNING_SECRET)
|
|
1001
1059
|
enable_scheduled_digests: 每日/每周摘要邮件。可通过 SolidQueue、Sidekiq 或 cron 调度
|
|
1002
1060
|
digest_frequency: 摘要邮件的发送频率
|
|
1003
1061
|
digest_recipients: 邮件地址(默认使用通知收件人)
|
|
@@ -1015,7 +1073,7 @@ zh-CN:
|
|
|
1015
1073
|
source_code_context_lines: 目标行前后显示的行数
|
|
1016
1074
|
enable_git_blame: 显示 git blame 信息
|
|
1017
1075
|
source_code_cache_ttl: 源代码缓存时长
|
|
1018
|
-
only_show_app_code_source:
|
|
1076
|
+
only_show_app_code_source: "在源码查看器中隐藏 gem 和 vendor 代码"
|
|
1019
1077
|
git_branch_strategy: 分支解析策略
|
|
1020
1078
|
enable_breadcrumbs: 记录请求的活动轨迹
|
|
1021
1079
|
breadcrumb_buffer_size: 每个请求最多记录的 breadcrumbs 数
|
|
@@ -1039,6 +1097,7 @@ zh-CN:
|
|
|
1039
1097
|
enable_diagnostic_dump: 按需生成系统状态快照
|
|
1040
1098
|
enable_crash_capture: 用 at_exit 钩子捕获未处理的崩溃
|
|
1041
1099
|
enable_coverage_tracking: 诊断模式 — 在面板中启用后,可在源码视图中看到已执行的行(Ruby 3.2+)
|
|
1100
|
+
enable_missing_translation_tracking: "按语言区域和键统计应用的 I18n 缺失(包装 I18n.exception_handler)"
|
|
1042
1101
|
enable_rack_attack_tracking: 将 throttle/blocklist 事件记录到独立数据表
|
|
1043
1102
|
enable_actioncable_tracking: 将 WebSocket 频道事件记录为 breadcrumbs
|
|
1044
1103
|
enable_activestorage_tracking: 将上传/下载/删除记录为 breadcrumbs
|
|
@@ -1084,6 +1143,8 @@ zh-CN:
|
|
|
1084
1143
|
breadcrumbs: Breadcrumbs
|
|
1085
1144
|
system_health: 系统状态
|
|
1086
1145
|
swallowed_exceptions: 被吞异常检测
|
|
1146
|
+
missing_translations: "缺失翻译跟踪"
|
|
1147
|
+
boot_errors: "启动错误捕获"
|
|
1087
1148
|
rack_attack: Rack Attack 跟踪
|
|
1088
1149
|
actioncable: ActionCable 跟踪
|
|
1089
1150
|
activestorage: ActiveStorage 跟踪
|
|
@@ -1446,6 +1507,13 @@ zh-CN:
|
|
|
1446
1507
|
guide_link: ActionCable 指南
|
|
1447
1508
|
activestorage:
|
|
1448
1509
|
title: ActiveStorage 状态
|
|
1510
|
+
probe:
|
|
1511
|
+
title: "服务可达性"
|
|
1512
|
+
reachable: "可达"
|
|
1513
|
+
unreachable: "不可达"
|
|
1514
|
+
service: "服务:%{name}"
|
|
1515
|
+
latency: "往返 %{ms} ms"
|
|
1516
|
+
how: "加载本页时对一个永远不会存储的键做一次存在性检查。GET /health 执行同样的探测。"
|
|
1449
1517
|
empty:
|
|
1450
1518
|
title: 未找到 ActiveStorage 事件
|
|
1451
1519
|
message: 最近 %{days} 天的错误 breadcrumbs 中未检测到 ActiveStorage 的服务操作。
|
data/config/routes.rb
CHANGED
|
@@ -7,6 +7,10 @@ RailsErrorDashboard::Engine.routes.draw do
|
|
|
7
7
|
# Settings page
|
|
8
8
|
get "settings", to: "errors#settings", as: :settings
|
|
9
9
|
|
|
10
|
+
# Health check (JSON): error database, queue, storm breaker, gem version.
|
|
11
|
+
# 200 when the dashboard can do its job, 503 when the error DB is unreachable.
|
|
12
|
+
get "health", to: "health#show", as: :health
|
|
13
|
+
|
|
10
14
|
# Per-user dashboard language, persisted in the session (P5-T1).
|
|
11
15
|
# POST only: changing it is a state change, and a GET would let any link or
|
|
12
16
|
# prefetch alter the user's language.
|
|
@@ -41,6 +45,8 @@ RailsErrorDashboard::Engine.routes.draw do
|
|
|
41
45
|
get :database_health_summary
|
|
42
46
|
get :swallowed_exceptions
|
|
43
47
|
get :rack_attack_summary
|
|
48
|
+
get :missing_translations
|
|
49
|
+
get :boot_errors
|
|
44
50
|
get :actioncable_health_summary
|
|
45
51
|
get :activestorage_health_summary
|
|
46
52
|
get :llm_health_summary
|
|
@@ -26,7 +26,7 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
|
|
|
26
26
|
t.string :error_type, null: false
|
|
27
27
|
t.text :message, null: false
|
|
28
28
|
t.text :backtrace
|
|
29
|
-
t.
|
|
29
|
+
t.column :user_id, user_id_type
|
|
30
30
|
t.text :request_url
|
|
31
31
|
t.text :request_params
|
|
32
32
|
t.text :user_agent
|
|
@@ -157,7 +157,7 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
|
|
|
157
157
|
create_table :rails_error_dashboard_error_occurrences do |t|
|
|
158
158
|
t.bigint :error_log_id, null: false
|
|
159
159
|
t.datetime :occurred_at, null: false
|
|
160
|
-
t.
|
|
160
|
+
t.column :user_id, user_id_type
|
|
161
161
|
t.string :request_id
|
|
162
162
|
t.string :session_id
|
|
163
163
|
t.timestamps
|
|
@@ -275,4 +275,12 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
|
|
|
275
275
|
drop_table :rails_error_dashboard_error_logs, if_exists: true
|
|
276
276
|
drop_table :rails_error_dashboard_applications, if_exists: true
|
|
277
277
|
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
# Same type as the app's primary keys, as ActiveStorage does for record_id.
|
|
282
|
+
def user_id_type
|
|
283
|
+
config = Rails.configuration.generators
|
|
284
|
+
config.options[config.orm][:primary_key_type] || :integer
|
|
285
|
+
end
|
|
278
286
|
end
|
|
@@ -3,6 +3,15 @@ class RemoveEnvironmentFromErrorLogs < ActiveRecord::Migration[7.0]
|
|
|
3
3
|
# Skip if squashed migration ran (column never existed) or already removed
|
|
4
4
|
return unless column_exists?(:rails_error_dashboard_error_logs, :environment)
|
|
5
5
|
|
|
6
|
+
# Only the v0.1.x column is removed: it was NOT NULL. The column
|
|
7
|
+
# AddEnvironmentToErrorLogs adds back (0.11.0) is nullable and holds real
|
|
8
|
+
# data. This migration replays over it whenever RED's migrations run
|
|
9
|
+
# against tables that already exist (an app joining a shared error
|
|
10
|
+
# database runs its own renumbered copy of every migration), and dropping
|
|
11
|
+
# it there wiped every error's environment.
|
|
12
|
+
environment = columns(:rails_error_dashboard_error_logs).find { |column| column.name == "environment" }
|
|
13
|
+
return if environment.nil? || environment.null
|
|
14
|
+
|
|
6
15
|
# Remove composite index first
|
|
7
16
|
remove_index :rails_error_dashboard_error_logs,
|
|
8
17
|
name: 'index_error_logs_on_environment_and_occurred_at',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# One row per (locale, key, application) the host app failed to translate.
|
|
4
|
+
#
|
|
5
|
+
# A missing translation is not an error: Rails renders "translation missing"
|
|
6
|
+
# (or the humanized key) and the request succeeds, so it never reaches
|
|
7
|
+
# error_logs and nothing in the dashboard would otherwise show it. The
|
|
8
|
+
# tracker counts misses in memory and upserts them here at the end of the
|
|
9
|
+
# request or job that produced them -- a loop rendering a missing key a
|
|
10
|
+
# thousand times is one UPDATE, not a thousand INSERTs.
|
|
11
|
+
class CreateMissingTranslations < ActiveRecord::Migration[7.0]
|
|
12
|
+
def change
|
|
13
|
+
# Guard against the squashed schema migration having already created this
|
|
14
|
+
# table -- without it, every later migration is silently cancelled.
|
|
15
|
+
return if table_exists?(:rails_error_dashboard_missing_translations)
|
|
16
|
+
|
|
17
|
+
create_table :rails_error_dashboard_missing_translations do |t|
|
|
18
|
+
# BCP 47 tags ("pt-BR", "zh-Hans-CN", "en-US-x-twain") fit in 35.
|
|
19
|
+
t.string :locale, null: false, limit: 35
|
|
20
|
+
# The full dotted key as I18n normalised it ("users.show.greeting").
|
|
21
|
+
# Capped at 191 so the unique upsert index stays inside MySQL's
|
|
22
|
+
# 3072-byte utf8mb4 limit: (35 + 191) * 4 + application_id. The tracker
|
|
23
|
+
# truncates before the value reaches the index.
|
|
24
|
+
t.string :translation_key, null: false, limit: 191
|
|
25
|
+
# First host call site seen for this key ("app/views/users/show.html.erb:12").
|
|
26
|
+
# First-write-wins; not part of the row's identity.
|
|
27
|
+
t.string :source, limit: 250
|
|
28
|
+
t.bigint :miss_count, null: false, default: 0
|
|
29
|
+
t.datetime :first_seen_at, null: false
|
|
30
|
+
t.datetime :last_seen_at, null: false
|
|
31
|
+
# Nullable: a miss recorded before application scoping, or with no
|
|
32
|
+
# application_name configured, applies to the whole install.
|
|
33
|
+
t.bigint :application_id
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Named explicitly: an auto-generated name would exceed PostgreSQL's
|
|
38
|
+
# 63-character limit and fail during the HOST app's deploy.
|
|
39
|
+
add_index :rails_error_dashboard_missing_translations,
|
|
40
|
+
[ :locale, :translation_key, :application_id ],
|
|
41
|
+
unique: true,
|
|
42
|
+
name: "index_red_missing_translations_upsert_key"
|
|
43
|
+
|
|
44
|
+
# The dashboard reads "misses seen in the last N days" and retention
|
|
45
|
+
# prunes by the same column.
|
|
46
|
+
add_index :rails_error_dashboard_missing_translations,
|
|
47
|
+
:last_seen_at,
|
|
48
|
+
name: "index_red_missing_translations_on_last_seen_at"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -12,6 +12,7 @@ module RailsErrorDashboard
|
|
|
12
12
|
class_option :slack, type: :boolean, default: false, desc: "Enable Slack notifications"
|
|
13
13
|
class_option :email, type: :boolean, default: false, desc: "Enable email notifications"
|
|
14
14
|
class_option :discord, type: :boolean, default: false, desc: "Enable Discord notifications"
|
|
15
|
+
class_option :telegram, type: :boolean, default: false, desc: "Enable Telegram notifications"
|
|
15
16
|
class_option :pagerduty, type: :boolean, default: false, desc: "Enable PagerDuty notifications"
|
|
16
17
|
class_option :webhooks, type: :boolean, default: false, desc: "Enable webhook notifications"
|
|
17
18
|
# Performance options
|
|
@@ -58,7 +59,7 @@ module RailsErrorDashboard
|
|
|
58
59
|
|
|
59
60
|
if quick_mode?
|
|
60
61
|
@selected_features = build_quick_defaults
|
|
61
|
-
say " Using sensible defaults (analytics ON,
|
|
62
|
+
say " Using sensible defaults (analytics, breadcrumbs ON, system health ON, 50% sampling; notifications OFF)", :green
|
|
62
63
|
return
|
|
63
64
|
end
|
|
64
65
|
|
|
@@ -71,13 +72,13 @@ module RailsErrorDashboard
|
|
|
71
72
|
@selected_features = {}
|
|
72
73
|
|
|
73
74
|
# =====================================================================
|
|
74
|
-
# QUESTION 1: Notifications (gated — one y/N opens
|
|
75
|
+
# QUESTION 1: Notifications (gated — one y/N opens 6 sub-questions)
|
|
75
76
|
# =====================================================================
|
|
76
|
-
notification_keys = %i[slack email discord pagerduty webhooks]
|
|
77
|
+
notification_keys = %i[slack email discord telegram pagerduty webhooks]
|
|
77
78
|
any_notification_cli_flag = notification_keys.any? { |k| options[k] }
|
|
78
79
|
|
|
79
80
|
say "[1/3] Notifications [background/dashboard only — zero request overhead]", :cyan
|
|
80
|
-
say " Alert your team via Slack, email, Discord, PagerDuty, or webhooks.", :white
|
|
81
|
+
say " Alert your team via Slack, email, Discord, Telegram, PagerDuty, or webhooks.", :white
|
|
81
82
|
|
|
82
83
|
if any_notification_cli_flag
|
|
83
84
|
# Individual CLI flags passed — respect them, skip the gate prompt
|
|
@@ -90,6 +91,7 @@ module RailsErrorDashboard
|
|
|
90
91
|
{ key: :slack, name: "Slack", hint: "SLACK_WEBHOOK_URL" },
|
|
91
92
|
{ key: :email, name: "Email", hint: "ERROR_NOTIFICATION_EMAILS" },
|
|
92
93
|
{ key: :discord, name: "Discord", hint: "DISCORD_WEBHOOK_URL" },
|
|
94
|
+
{ key: :telegram, name: "Telegram", hint: "TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID" },
|
|
93
95
|
{ key: :pagerduty, name: "PagerDuty", hint: "PAGERDUTY_INTEGRATION_KEY" },
|
|
94
96
|
{ key: :webhooks, name: "Webhooks", hint: "WEBHOOK_URLS" }
|
|
95
97
|
]
|
|
@@ -131,7 +133,10 @@ module RailsErrorDashboard
|
|
|
131
133
|
advanced_keys = %i[async_logging error_sampling breadcrumbs system_health
|
|
132
134
|
source_code_integration git_blame swallowed_exceptions
|
|
133
135
|
crash_capture diagnostic_dump]
|
|
134
|
-
|
|
136
|
+
# A flag the user passed, not an option left at its default: async_logging
|
|
137
|
+
# defaults to true, so "is any of these set?" was always yes and this
|
|
138
|
+
# question was never asked.
|
|
139
|
+
any_advanced_cli_flag = advanced_keys.any? { |k| options[k] != self.class.class_options[k]&.default }
|
|
135
140
|
|
|
136
141
|
say "\n[3/3] Advanced Options (performance tuning & diagnostics)", :cyan
|
|
137
142
|
say " Async logging, error sampling, breadcrumbs, system health,", :white
|
|
@@ -169,8 +174,10 @@ module RailsErrorDashboard
|
|
|
169
174
|
advanced_features.each do |f|
|
|
170
175
|
say "\n #{f[:name]}", :cyan
|
|
171
176
|
say " #{f[:desc]}", :white
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
# Async logging is on unless declined, as it is without this question.
|
|
178
|
+
default_on = f[:key] == :async_logging
|
|
179
|
+
r = ask(" Enable? (#{default_on ? 'Y/n' : 'y/N'}):", :yellow, limited_to: [ "y", "Y", "n", "N", "" ])
|
|
180
|
+
@selected_features[f[:key]] = default_on ? r.downcase != "n" : r.downcase == "y"
|
|
174
181
|
if @selected_features[f[:key]] && f[:key] == :swallowed_exceptions && RUBY_VERSION < "3.3"
|
|
175
182
|
say " ⚠ Requires Ruby 3.3+ (you have #{RUBY_VERSION}) — will activate after upgrade", :yellow
|
|
176
183
|
end
|
|
@@ -289,11 +296,14 @@ module RailsErrorDashboard
|
|
|
289
296
|
@enable_slack = @selected_features&.dig(:slack) || options[:slack]
|
|
290
297
|
@enable_email = @selected_features&.dig(:email) || options[:email]
|
|
291
298
|
@enable_discord = @selected_features&.dig(:discord) || options[:discord]
|
|
299
|
+
@enable_telegram = @selected_features&.dig(:telegram) || options[:telegram]
|
|
292
300
|
@enable_pagerduty = @selected_features&.dig(:pagerduty) || options[:pagerduty]
|
|
293
301
|
@enable_webhooks = @selected_features&.dig(:webhooks) || options[:webhooks]
|
|
294
302
|
|
|
295
303
|
# Performance
|
|
296
|
-
|
|
304
|
+
# An answer, when there was one, wins: `answer || default` turned a "no"
|
|
305
|
+
# into the default's "yes".
|
|
306
|
+
@enable_async_logging = @selected_features&.key?(:async_logging) ? @selected_features[:async_logging] : options[:async_logging]
|
|
297
307
|
@enable_error_sampling = @selected_features&.dig(:error_sampling) || options[:error_sampling]
|
|
298
308
|
|
|
299
309
|
# Database mode (set by select_database_mode or CLI flags)
|
|
@@ -410,6 +420,7 @@ module RailsErrorDashboard
|
|
|
410
420
|
notification_features << "Slack" if @enable_slack
|
|
411
421
|
notification_features << "Email" if @enable_email
|
|
412
422
|
notification_features << "Discord" if @enable_discord
|
|
423
|
+
notification_features << "Telegram" if @enable_telegram
|
|
413
424
|
notification_features << "PagerDuty" if @enable_pagerduty
|
|
414
425
|
notification_features << "Webhooks" if @enable_webhooks
|
|
415
426
|
|
|
@@ -474,6 +485,7 @@ module RailsErrorDashboard
|
|
|
474
485
|
say " → Set SLACK_WEBHOOK_URL in .env", :yellow if @enable_slack
|
|
475
486
|
say " → Set ERROR_NOTIFICATION_EMAILS in .env", :yellow if @enable_email
|
|
476
487
|
say " → Set DISCORD_WEBHOOK_URL in .env", :yellow if @enable_discord
|
|
488
|
+
say " → Set TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID in .env", :yellow if @enable_telegram
|
|
477
489
|
say " → Set PAGERDUTY_INTEGRATION_KEY in .env", :yellow if @enable_pagerduty
|
|
478
490
|
say " → Set WEBHOOK_URLS in .env", :yellow if @enable_webhooks
|
|
479
491
|
if @enable_async_logging
|
|
@@ -493,12 +505,12 @@ module RailsErrorDashboard
|
|
|
493
505
|
say "Next Steps:", :cyan
|
|
494
506
|
if @enable_separate_database
|
|
495
507
|
say " 1. Add the database.yml entry shown above"
|
|
496
|
-
say " 2. Run: rails db:create
|
|
508
|
+
say " 2. Run: rails db:create:#{@database_name}"
|
|
497
509
|
if @enable_multi_app
|
|
498
510
|
say " 3. Run migrations (only needed on the FIRST app):"
|
|
499
|
-
say " rails db:migrate
|
|
511
|
+
say " rails db:migrate:#{@database_name}"
|
|
500
512
|
else
|
|
501
|
-
say " 3. Run: rails db:migrate
|
|
513
|
+
say " 3. Run: rails db:migrate:#{@database_name}"
|
|
502
514
|
end
|
|
503
515
|
say " 4. Before deploying: set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD there"
|
|
504
516
|
say " 5. Restart your Rails server"
|
|
@@ -562,7 +574,7 @@ module RailsErrorDashboard
|
|
|
562
574
|
system_health: true, # ~1ms per error — GC, memory, threads at exact error moment
|
|
563
575
|
error_sampling: true, # 50% sampling on non-critical errors — halves storage, still shows patterns
|
|
564
576
|
# OFF — require credentials, config, or niche use case
|
|
565
|
-
slack: false, email: false, discord: false, pagerduty: false, webhooks: false,
|
|
577
|
+
slack: false, email: false, discord: false, telegram: false, pagerduty: false, webhooks: false,
|
|
566
578
|
source_code_integration: false,
|
|
567
579
|
git_blame: false,
|
|
568
580
|
swallowed_exceptions: false,
|
|
@@ -595,33 +607,23 @@ module RailsErrorDashboard
|
|
|
595
607
|
say " # Separate error database", :white
|
|
596
608
|
end
|
|
597
609
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
say "
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
say " <<: *default", :white
|
|
615
|
-
say " database: #{app_name_snake}_production", :white
|
|
616
|
-
say " error_dashboard:", :white
|
|
617
|
-
say " <<: *default", :white
|
|
618
|
-
if @enable_multi_app
|
|
619
|
-
shared_db_base = @shared_db_name || "shared_errors"
|
|
620
|
-
say " database: #{shared_db_base}_production", :white
|
|
621
|
-
else
|
|
622
|
-
say " database: #{app_name_snake}_errors_production", :white
|
|
610
|
+
# Every environment the app boots in needs the entry, test included:
|
|
611
|
+
# without it RED falls back to the main database and records nothing.
|
|
612
|
+
%w[development test production].each do |env|
|
|
613
|
+
say "\n #{env}:", :cyan
|
|
614
|
+
say " primary:", :white
|
|
615
|
+
say " <<: *default", :white
|
|
616
|
+
say " database: #{app_name_snake}_#{env}", :white
|
|
617
|
+
say " #{@database_name}:", :white
|
|
618
|
+
say " <<: *default", :white
|
|
619
|
+
if @enable_multi_app
|
|
620
|
+
shared_db_base = @shared_db_name || "shared_errors"
|
|
621
|
+
say " database: #{shared_db_base}_#{env}", :white
|
|
622
|
+
else
|
|
623
|
+
say " database: #{app_name_snake}_errors_#{env}", :white
|
|
624
|
+
end
|
|
625
|
+
say " migrations_paths: db/error_dashboard_migrate", :white
|
|
623
626
|
end
|
|
624
|
-
say " migrations_paths: db/error_dashboard_migrate", :white
|
|
625
627
|
say "\n"
|
|
626
628
|
|
|
627
629
|
if @enable_multi_app
|
|
@@ -127,6 +127,21 @@ RailsErrorDashboard.configure do |config|
|
|
|
127
127
|
config.enable_discord_notifications = false
|
|
128
128
|
# config.discord_webhook_url = ENV["DISCORD_WEBHOOK_URL"]
|
|
129
129
|
|
|
130
|
+
<% end -%>
|
|
131
|
+
<% if @enable_telegram -%>
|
|
132
|
+
# Telegram Notifications - ENABLED
|
|
133
|
+
config.enable_telegram_notifications = true
|
|
134
|
+
config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
135
|
+
config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
136
|
+
# To disable: Set config.enable_telegram_notifications = false
|
|
137
|
+
|
|
138
|
+
<% else -%>
|
|
139
|
+
# Telegram Notifications - DISABLED
|
|
140
|
+
# To enable: Set config.enable_telegram_notifications = true and configure a bot token and chat id
|
|
141
|
+
config.enable_telegram_notifications = false
|
|
142
|
+
# config.telegram_bot_token = ENV["TELEGRAM_BOT_TOKEN"]
|
|
143
|
+
# config.telegram_chat_id = ENV["TELEGRAM_CHAT_ID"]
|
|
144
|
+
|
|
130
145
|
<% end -%>
|
|
131
146
|
<% if @enable_pagerduty -%>
|
|
132
147
|
# PagerDuty Integration - ENABLED (critical errors only)
|
|
@@ -145,6 +160,9 @@ RailsErrorDashboard.configure do |config|
|
|
|
145
160
|
# Generic Webhook Notifications - ENABLED
|
|
146
161
|
config.enable_webhook_notifications = true
|
|
147
162
|
config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
163
|
+
# Sign every webhook (HMAC-SHA256 over timestamp + body) so receivers can
|
|
164
|
+
# verify it came from this app. Generate with: ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'
|
|
165
|
+
# config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"]
|
|
148
166
|
# To disable: Set config.enable_webhook_notifications = false
|
|
149
167
|
|
|
150
168
|
<% else -%>
|
|
@@ -152,6 +170,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
152
170
|
# To enable: Set config.enable_webhook_notifications = true and configure webhook URLs
|
|
153
171
|
config.enable_webhook_notifications = false
|
|
154
172
|
# config.webhook_urls = ENV.fetch("WEBHOOK_URLS", "").split(",").map(&:strip).reject(&:empty?)
|
|
173
|
+
# config.webhook_signing_secret = ENV["WEBHOOK_SIGNING_SECRET"] # HMAC-SHA256 signature headers
|
|
155
174
|
|
|
156
175
|
<% end -%>
|
|
157
176
|
# Dashboard base URL (used in notification links)
|
|
@@ -186,7 +205,8 @@ RailsErrorDashboard.configure do |config|
|
|
|
186
205
|
<% if @enable_error_sampling -%>
|
|
187
206
|
# Error Sampling - ENABLED
|
|
188
207
|
# Samples non-critical errors to reduce storage volume.
|
|
189
|
-
# Critical
|
|
208
|
+
# Critical errors are always logged, regardless of this setting. So is the first
|
|
209
|
+
# occurrence of each error in each process.
|
|
190
210
|
# 0.5 = log 50% of non-critical occurrences — halves storage while keeping
|
|
191
211
|
# occurrence counts meaningful and error patterns visible.
|
|
192
212
|
# Tune lower (e.g. 0.1) if one error is flooding the DB; set to 1.0 to log everything.
|
|
@@ -244,7 +264,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
244
264
|
# Automatically detect when error rates exceed normal patterns
|
|
245
265
|
config.enable_baseline_alerts = true
|
|
246
266
|
config.baseline_alert_threshold_std_devs = 2.0 # Alert when > 2 std devs above baseline
|
|
247
|
-
config.baseline_alert_severities = [ :critical, :high ]
|
|
267
|
+
config.baseline_alert_severities = [ :critical, :high ] # Only errors of these severities alert
|
|
248
268
|
config.baseline_alert_cooldown_minutes = 120 # 2 hours between alerts
|
|
249
269
|
# To disable: Set config.enable_baseline_alerts = false
|
|
250
270
|
|
|
@@ -253,7 +273,7 @@ RailsErrorDashboard.configure do |config|
|
|
|
253
273
|
# To enable: Set config.enable_baseline_alerts = true
|
|
254
274
|
config.enable_baseline_alerts = false
|
|
255
275
|
# config.baseline_alert_threshold_std_devs = 2.0
|
|
256
|
-
# config.baseline_alert_severities = [ :critical, :high ]
|
|
276
|
+
# config.baseline_alert_severities = [ :critical, :high ] # Only errors of these severities alert
|
|
257
277
|
# config.baseline_alert_cooldown_minutes = 120
|
|
258
278
|
|
|
259
279
|
<% end -%>
|
|
@@ -407,6 +427,14 @@ RailsErrorDashboard.configure do |config|
|
|
|
407
427
|
# config.swallowed_exception_threshold = 0.95
|
|
408
428
|
|
|
409
429
|
<% end -%>
|
|
430
|
+
# Missing-Translation Tracking - DISABLED
|
|
431
|
+
# Counts every I18n key your app looked up and did not have, by locale, with
|
|
432
|
+
# the first call site, on its own dashboard page (/errors/missing_translations).
|
|
433
|
+
# Wraps I18n.exception_handler and delegates to the handler you already had —
|
|
434
|
+
# the one place RED touches global I18n state, which is why it is opt-in.
|
|
435
|
+
# To enable: Set config.enable_missing_translation_tracking = true
|
|
436
|
+
config.enable_missing_translation_tracking = false
|
|
437
|
+
|
|
410
438
|
<% if @enable_diagnostic_dump -%>
|
|
411
439
|
# Diagnostic Dump - ENABLED
|
|
412
440
|
# On-demand system state snapshot via rake task or dashboard button
|
|
@@ -236,13 +236,22 @@ module RailsErrorDashboard
|
|
|
236
236
|
say "Remaining manual steps:", :cyan
|
|
237
237
|
say "\n"
|
|
238
238
|
|
|
239
|
-
|
|
239
|
+
unless options[:keep_data]
|
|
240
|
+
say "1. Regenerate the schema file:", :yellow
|
|
241
|
+
say " Run: bin/rails db:schema:dump (otherwise db:schema:load recreates the tables)"
|
|
242
|
+
say "\n"
|
|
243
|
+
|
|
244
|
+
say "2. Separate database only: remove its entry from config/database.yml", :yellow
|
|
245
|
+
say "\n"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
say "#{options[:keep_data] ? 1 : 3}. Remove from Gemfile:", :yellow
|
|
240
249
|
say " Open: Gemfile"
|
|
241
250
|
say " Remove: gem 'rails_error_dashboard'"
|
|
242
251
|
say " Run: bundle install"
|
|
243
252
|
say "\n"
|
|
244
253
|
|
|
245
|
-
say "2. Restart your application:", :yellow
|
|
254
|
+
say "#{options[:keep_data] ? 2 : 4}. Restart your application:", :yellow
|
|
246
255
|
say " Run: rails restart"
|
|
247
256
|
say " Or: kill and restart your server process"
|
|
248
257
|
say "\n"
|
|
@@ -29,10 +29,33 @@ module RailsErrorDashboard
|
|
|
29
29
|
error.update!(
|
|
30
30
|
assigned_to: assignee.truncate(MAX_ASSIGNEE_LENGTH, omission: ""),
|
|
31
31
|
assigned_at: Time.current,
|
|
32
|
-
|
|
32
|
+
**status_attributes(error)
|
|
33
33
|
)
|
|
34
|
+
# The stat cards are cached; a reopened error must show up at once.
|
|
35
|
+
Services::AnalyticsCacheManager.clear if error.saved_change_to_resolved?
|
|
36
|
+
|
|
34
37
|
{ success: true, error: error }
|
|
35
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Assigning means someone is working on it, so the error moves to
|
|
43
|
+
# in_progress. Doing that to a resolved error used to leave
|
|
44
|
+
# resolved: true and resolved_at behind: a row that was in_progress to
|
|
45
|
+
# the eye, resolved to the scopes, and matched by neither the unresolved
|
|
46
|
+
# nor the resolved lookup, so its next recurrence opened a new row.
|
|
47
|
+
# Assigning a resolved error reopens it. wont_fix is sticky (H-6): the
|
|
48
|
+
# assignee is recorded and the status stays.
|
|
49
|
+
def status_attributes(error)
|
|
50
|
+
return {} if error.status == "wont_fix"
|
|
51
|
+
|
|
52
|
+
attrs = { status: "in_progress" }
|
|
53
|
+
if error.resolved? || error.status == "resolved"
|
|
54
|
+
attrs[:resolved] = false
|
|
55
|
+
attrs[:resolved_at] = nil
|
|
56
|
+
end
|
|
57
|
+
attrs
|
|
58
|
+
end
|
|
36
59
|
end
|
|
37
60
|
end
|
|
38
61
|
end
|
|
@@ -44,11 +44,19 @@ module RailsErrorDashboard
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# Dispatch plugin event for batch resolved errors
|
|
48
47
|
Services::AnalyticsCacheManager.clear if resolved_count.positive?
|
|
49
48
|
|
|
49
|
+
# Dispatch plugin event for batch resolved errors
|
|
50
50
|
PluginRegistry.dispatch(:on_errors_batch_resolved, resolved_errors) if resolved_errors.any?
|
|
51
51
|
|
|
52
|
+
# Each error resolved here is as resolved as one resolved on its own
|
|
53
|
+
# page: the host's error_resolved callbacks (the issue tracker closes
|
|
54
|
+
# the linked issue from one) and the instrumentation event fire per
|
|
55
|
+
# error. Only the plugin event is batch-shaped, by design.
|
|
56
|
+
resolved_errors.each do |error|
|
|
57
|
+
Services::ResolutionCallbacks.call(error, resolved_by: @resolved_by_name)
|
|
58
|
+
end
|
|
59
|
+
|
|
52
60
|
{
|
|
53
61
|
success: failed_ids.empty?,
|
|
54
62
|
count: resolved_count,
|