rails_error_dashboard 0.15.0 → 0.16.1

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -4
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +29 -2
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
  5. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +7 -14
  6. data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
  7. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +5 -0
  8. data/app/models/rails_error_dashboard/error_log.rb +11 -8
  9. data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
  10. data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
  11. data/app/views/layouts/rails_error_dashboard.html.erb +3 -1
  12. data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
  13. data/app/views/rails_error_dashboard/errors/_error_info.html.erb +10 -3
  14. data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
  15. data/app/views/rails_error_dashboard/errors/correlation.html.erb +2 -2
  16. data/app/views/rails_error_dashboard/errors/overview.html.erb +14 -7
  17. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +26 -17
  18. data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
  19. data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
  20. data/config/locales/de.yml +21 -1
  21. data/config/locales/en.yml +23 -1
  22. data/config/locales/es.yml +22 -1
  23. data/config/locales/fr.yml +22 -1
  24. data/config/locales/it.yml +22 -1
  25. data/config/locales/ja.yml +20 -1
  26. data/config/locales/pl.yml +23 -1
  27. data/config/locales/pt-BR.yml +22 -1
  28. data/config/locales/ru.yml +23 -1
  29. data/config/locales/uk.yml +23 -1
  30. data/config/locales/zh-CN.yml +20 -1
  31. data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
  32. data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
  33. data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
  34. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
  35. data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
  36. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
  37. data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
  38. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
  39. data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
  40. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
  41. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
  42. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
  43. data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
  44. data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
  45. data/lib/rails_error_dashboard/configuration.rb +26 -3
  46. data/lib/rails_error_dashboard/error_reporter.rb +4 -2
  47. data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
  48. data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
  49. data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
  50. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
  51. data/lib/rails_error_dashboard/queries/platform_comparison.rb +55 -18
  52. data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
  53. data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
  54. data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
  55. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
  56. data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
  57. data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
  58. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
  59. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
  60. data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
  61. data/lib/rails_error_dashboard/version.rb +1 -1
  62. data/lib/rails_error_dashboard.rb +16 -0
  63. data/lib/tasks/error_dashboard.rake +2 -3
  64. metadata +10 -2
@@ -36,6 +36,9 @@ ru:
36
36
  healthy: "✅ В норме"
37
37
  warning: "⚠️ Предупреждение"
38
38
  critical: "\U0001F534 Критично"
39
+ not_rated: "Без оценки"
40
+ not_rated_hint: "Оценка сравнивает платформы, поэтому нужны ошибки как минимум на двух."
41
+ nothing_resolved: "Ни одна ошибка за этот период ещё не решена"
39
42
  device_type:
40
43
  mobile: Мобильное
41
44
  tablet: Планшет
@@ -248,6 +251,10 @@ ru:
248
251
  inactive_body: включите, чтобы видеть выполненные строки
249
252
  enable: Включить
250
253
  disable: Отключить
254
+ occurrence:
255
+ title: "Событие #%{id}"
256
+ body: "Breadcrumbs, состояние системы и переменные относятся к этому событию. Всё остальное показывает последний захват."
257
+ show_latest: "Показать последнее"
251
258
  baselines:
252
259
  title: Статистика базовой линии
253
260
  anomaly_label: 'Аномалия:'
@@ -362,7 +369,7 @@ ru:
362
369
  types:
363
370
  business_hours:
364
371
  label: Рабочие часы
365
- description: Пик ошибок приходится на рабочие часы (9:00–17:00), что указывает
372
+ description: Пик ошибок приходится на рабочие часы (9:00–17:59), что указывает
366
373
  на связь с активностью пользователей
367
374
  night:
368
375
  label: Ночная закономерность
@@ -523,6 +530,14 @@ ru:
523
530
  few: "+ ещё %{count}"
524
531
  many: "+ ещё %{count}"
525
532
  view_errors: Смотреть ошибки
533
+ captured_occurrences:
534
+ title: "Сохранённые события"
535
+ hint: "Breadcrumbs, состояние системы и переменные хранятся для последних событий (occurrence_context_limit). Откройте событие, чтобы их увидеть."
536
+ column_occurrence: "Событие"
537
+ column_occurred_at: "Время"
538
+ column_version: "Версия"
539
+ view: "Открыть"
540
+ viewing: "Открыто"
526
541
  co_occurring:
527
542
  title: Сопутствующие ошибки
528
543
  badge: Временные закономерности
@@ -777,6 +792,7 @@ ru:
777
792
  many: "%{count} кадров"
778
793
  click_to_expand: "(нажмите, чтобы развернуть)"
779
794
  view_source: Смотреть исходный код
795
+ client_report_note: "Эти фреймы пришли из отчёта клиента (ManualErrorReporter). Они описывают код, выполнявшийся на клиенте, поэтому RED не открывает для них исходные файлы, не показывает фрагменты и не запускает git blame."
780
796
  none: Backtrace недоступен
781
797
  row:
782
798
  new_badge: НОВАЯ
@@ -1157,6 +1173,11 @@ ru:
1157
1173
  other: "%{count} события"
1158
1174
  few: "%{count} события"
1159
1175
  many: "%{count} событий"
1176
+ occurrences:
1177
+ one: "%{count} событие"
1178
+ other: "%{count} события"
1179
+ few: "%{count} события"
1180
+ many: "%{count} событий"
1160
1181
  queries:
1161
1182
  one: "%{count} запрос"
1162
1183
  other: "%{count} запроса"
@@ -1211,6 +1232,7 @@ ru:
1211
1232
  enable_middleware: Перехватывает необработанные исключения
1212
1233
  enable_error_subscriber: Подписывается на события ошибок Rails
1213
1234
  retention_days: "Удалять ошибки, не встречавшиеся столько дней. Выполняет RetentionCleanupJob, который гем сам не планирует"
1235
+ occurrence_context_limit: "Сколько последних событий каждой ошибки сохраняют breadcrumbs, состояние системы и переменные (0 = выключено)"
1214
1236
  max_backtrace_lines: Ограничение глубины backtrace
1215
1237
  sampling_rate: Доля захватываемых ошибок
1216
1238
  application_name: Имя этого приложения (определяется автоматически, если не
@@ -36,6 +36,9 @@ uk:
36
36
  healthy: "✅ Справний"
37
37
  warning: "⚠️ Попередження"
38
38
  critical: "\U0001F534 Критичний"
39
+ not_rated: "Без оцінки"
40
+ not_rated_hint: "Оцінка порівнює платформи, тому потрібні помилки щонайменше на двох."
41
+ nothing_resolved: "Жодну помилку за цей період ще не вирішено"
39
42
  device_type:
40
43
  mobile: Mobile
41
44
  tablet: Tablet
@@ -248,6 +251,10 @@ uk:
248
251
  inactive_body: увімкніть, щоб бачити виконані рядки
249
252
  enable: Увімкнути
250
253
  disable: Вимкнути
254
+ occurrence:
255
+ title: "Подія #%{id}"
256
+ body: "Breadcrumbs, стан системи та змінні належать до цієї події. Усе інше показує останнє захоплення."
257
+ show_latest: "Показати останню"
251
258
  baselines:
252
259
  title: Статистика базового рівня
253
260
  anomaly_label: 'Аномалія:'
@@ -360,7 +367,7 @@ uk:
360
367
  types:
361
368
  business_hours:
362
369
  label: Закономірність робочих годин
363
- description: Пік помилок припадає на робочі години (9:00–17:00), що вказує
370
+ description: Пік помилок припадає на робочі години (9:00–17:59), що вказує
364
371
  на звʼязок з активністю користувачів
365
372
  night:
366
373
  label: Нічна закономірність
@@ -520,6 +527,14 @@ uk:
520
527
  few: "+%{count} ще"
521
528
  many: "+%{count} ще"
522
529
  view_errors: Переглянути помилки
530
+ captured_occurrences:
531
+ title: "Збережені події"
532
+ hint: "Breadcrumbs, стан системи та змінні зберігаються для останніх подій (occurrence_context_limit). Відкрийте подію, щоб їх побачити."
533
+ column_occurrence: "Подія"
534
+ column_occurred_at: "Час"
535
+ column_version: "Версія"
536
+ view: "Відкрити"
537
+ viewing: "Відкрито"
523
538
  co_occurring:
524
539
  title: Супутні помилки
525
540
  badge: Часові закономірності
@@ -775,6 +790,7 @@ uk:
775
790
  many: "%{count} кадрів"
776
791
  click_to_expand: "(натисніть, щоб розгорнути)"
777
792
  view_source: Переглянути код
793
+ client_report_note: "Ці фрейми надійшли зі звіту клієнта (ManualErrorReporter). Вони описують код, що виконувався на клієнті, тому RED не відкриває для них вихідні файли, не показує фрагменти й не запускає git blame."
778
794
  none: Backtrace недоступний
779
795
  row:
780
796
  new_badge: НОВА
@@ -1153,6 +1169,11 @@ uk:
1153
1169
  other: "%{count} події"
1154
1170
  few: "%{count} події"
1155
1171
  many: "%{count} подій"
1172
+ occurrences:
1173
+ one: "%{count} подія"
1174
+ other: "%{count} події"
1175
+ few: "%{count} події"
1176
+ many: "%{count} подій"
1156
1177
  queries:
1157
1178
  one: "%{count} запит"
1158
1179
  other: "%{count} запита"
@@ -1207,6 +1228,7 @@ uk:
1207
1228
  enable_middleware: Перехоплює необроблені винятки
1208
1229
  enable_error_subscriber: Підписується на події помилок Rails
1209
1230
  retention_days: "Видаляти помилки, яких не було стільки днів. Виконує RetentionCleanupJob, який гем сам не планує"
1231
+ occurrence_context_limit: "Скільки останніх подій кожної помилки зберігають breadcrumbs, стан системи та змінні (0 = вимкнено)"
1210
1232
  max_backtrace_lines: Обмеження глибини стеку викликів
1211
1233
  sampling_rate: Відсоток захоплених помилок
1212
1234
  application_name: Назва цього застосунку (визначається автоматично, якщо не
@@ -36,6 +36,9 @@ zh-CN:
36
36
  healthy: "✅ 正常"
37
37
  warning: "⚠️ 警告"
38
38
  critical: "\U0001F534 严重"
39
+ not_rated: "未评级"
40
+ not_rated_hint: "评级是在平台之间比较的,因此至少需要两个平台上的错误。"
41
+ nothing_resolved: "此期间的错误均尚未解决"
39
42
  device_type:
40
43
  mobile: Mobile
41
44
  tablet: Tablet
@@ -231,6 +234,10 @@ zh-CN:
231
234
  inactive_body: 启用后可查看已执行的行
232
235
  enable: 启用
233
236
  disable: 停用
237
+ occurrence:
238
+ title: "发生 #%{id}"
239
+ body: "Breadcrumbs、系统状态和变量来自这次发生。其余内容显示最新一次捕获。"
240
+ show_latest: "查看最新"
234
241
  baselines:
235
242
  title: 基线统计
236
243
  anomaly_label: 异常:
@@ -317,7 +324,7 @@ zh-CN:
317
324
  types:
318
325
  business_hours:
319
326
  label: 工作时段规律
320
- description: 错误高峰出现在工作时段(9:00–17:00),说明与用户活动相关
327
+ description: 错误高峰出现在工作时段(9:00–17:59),说明与用户活动相关
321
328
  night:
322
329
  label: 夜间规律
323
330
  description: 错误高峰出现在夜间(0:00–6:00),可能来自后台任务或定时作业
@@ -451,6 +458,14 @@ zh-CN:
451
458
  more:
452
459
  other: 另有 %{count} 项
453
460
  view_errors: 查看错误
461
+ captured_occurrences:
462
+ title: "已捕获的发生记录"
463
+ hint: "仅为最近的发生保留 Breadcrumbs、系统状态和变量(occurrence_context_limit)。打开某一条即可查看。"
464
+ column_occurrence: "发生"
465
+ column_occurred_at: "发生时间"
466
+ column_version: "版本"
467
+ view: "查看"
468
+ viewing: "正在查看"
454
469
  co_occurring:
455
470
  title: 同时出现的错误
456
471
  badge: 时间规律
@@ -679,6 +694,7 @@ zh-CN:
679
694
  other: "%{count} 个栈帧"
680
695
  click_to_expand: "(点击展开)"
681
696
  view_source: 查看源码
697
+ client_report_note: "这些帧来自客户端报告(ManualErrorReporter)。它们描述的是在客户端运行的代码,因此 RED 不会为其打开源文件、显示代码片段或运行 git blame。"
682
698
  none: 无可用的 backtrace
683
699
  row:
684
700
  new_badge: 新
@@ -986,6 +1002,8 @@ zh-CN:
986
1002
  other: "%{count} 秒"
987
1003
  events:
988
1004
  other: "%{count} 个事件"
1005
+ occurrences:
1006
+ other: "%{count} 次发生"
989
1007
  queries:
990
1008
  other: "%{count} 条查询"
991
1009
  std_devs: "%{count}σ"
@@ -1025,6 +1043,7 @@ zh-CN:
1025
1043
  enable_middleware: 捕获未处理的异常
1026
1044
  enable_error_subscriber: 订阅 Rails 错误事件
1027
1045
  retention_days: "删除这么多天内未再出现的错误。由 RetentionCleanupJob 执行,gem 本身不会安排它运行"
1046
+ occurrence_context_limit: "每个错误保留 Breadcrumbs、系统状态和变量的最近发生次数(0 = 关闭)"
1028
1047
  max_backtrace_lines: 堆栈跟踪深度上限
1029
1048
  sampling_rate: 错误捕获的采样比例
1030
1049
  application_name: 本应用的名称(未设置时自动检测)
@@ -3,9 +3,9 @@ class BackfillApplicationForExistingErrors < ActiveRecord::Migration[7.0]
3
3
  return if RailsErrorDashboard::ErrorLog.count.zero?
4
4
 
5
5
  # Create default application
6
- default_name = ENV['APPLICATION_NAME'] ||
7
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
8
- 'Legacy Application'
6
+ # The name new errors are captured under, config.application_name first,
7
+ # so old and new errors land in the same Application
8
+ default_name = RailsErrorDashboard.resolved_application_name
9
9
 
10
10
  app = RailsErrorDashboard::Application.find_or_create_by!(name: default_name) do |a|
11
11
  a.description = 'Auto-created during migration for existing errors'
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The moment-of-failure context of individual occurrences: breadcrumbs, system
4
+ # health, local and instance variables, as the error row stores them for its
5
+ # latest capture. The error row keeps only the latest; this keeps the last
6
+ # config.occurrence_context_limit occurrences per error (default 25).
7
+ #
8
+ # A side table, not columns on error_occurrences: the co-occurrence queries
9
+ # load occurrence rows whole, and these payloads run to several KB.
10
+ class CreateRailsErrorDashboardErrorOccurrenceContexts < ActiveRecord::Migration[7.0]
11
+ def change
12
+ # Guard against the squashed schema migration having already created this
13
+ # table -- without it, every later migration is silently cancelled.
14
+ return if table_exists?(:rails_error_dashboard_error_occurrence_contexts)
15
+
16
+ create_table :rails_error_dashboard_error_occurrence_contexts do |t|
17
+ t.bigint :error_occurrence_id, null: false
18
+ # Denormalized from the occurrence, so trimming to the newest N per error
19
+ # and retention's delete-by-error need no join
20
+ t.bigint :error_log_id, null: false
21
+ t.text :breadcrumbs
22
+ t.text :system_health
23
+ t.text :local_variables
24
+ t.text :instance_variables
25
+ t.datetime :created_at, null: false
26
+ end
27
+
28
+ # Named explicitly: an auto-generated name would exceed PostgreSQL's
29
+ # 63-character limit and fail during the HOST app's deploy.
30
+ add_index :rails_error_dashboard_error_occurrence_contexts, :error_occurrence_id,
31
+ unique: true, name: "index_red_occurrence_contexts_on_occurrence"
32
+ add_index :rails_error_dashboard_error_occurrence_contexts, :error_log_id,
33
+ name: "index_red_occurrence_contexts_on_error_log"
34
+ end
35
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Where each capture came from: "server" for RED's own captures (middleware,
4
+ # the Rails.error subscriber, jobs, crash import), "client" for errors sent
5
+ # through ManualErrorReporter by a browser, mobile app or other client.
6
+ #
7
+ # error_occurrences.capture_source how this occurrence was captured
8
+ # error_logs.backtrace_source where the backtrace the error page shows
9
+ # came from (the group's backtrace can be
10
+ # replaced by a later capture's)
11
+ #
12
+ # A client's backtrace describes code that ran on the client, so the features
13
+ # that read this app's source files for a frame (View Source, git blame, the
14
+ # snippets in Copy for LLM and AI help) skip it.
15
+ #
16
+ # Nullable, no default: NULL is a row from before this migration and reads as
17
+ # server, which is how those rows have always been treated. No index: nothing
18
+ # queries by it. Not part of the fingerprint: a client report and a server
19
+ # capture of the same error stay one group.
20
+ #
21
+ # up/down rather than `change`: the column_exists? guards that make `up` safe
22
+ # to re-run would make a reversed `change` skip the removal.
23
+ class AddCaptureSourceToErrorOccurrencesAndErrorLogs < ActiveRecord::Migration[7.0]
24
+ COLUMNS = {
25
+ rails_error_dashboard_error_occurrences: :capture_source,
26
+ rails_error_dashboard_error_logs: :backtrace_source
27
+ }.freeze
28
+
29
+ def up
30
+ COLUMNS.each do |table, column|
31
+ next unless table_exists?(table)
32
+ next if column_exists?(table, column)
33
+
34
+ add_column table, column, :string, limit: 10
35
+ end
36
+ end
37
+
38
+ def down
39
+ COLUMNS.each do |table, column|
40
+ next unless table_exists?(table)
41
+ next unless column_exists?(table, column)
42
+
43
+ remove_column table, column
44
+ end
45
+ end
46
+ end
@@ -41,11 +41,14 @@ RailsErrorDashboard.configure do |config|
41
41
  # warden, session, request, params, cookies, redirect_to, etc.
42
42
  # Return truthy to allow access, falsy to deny (403 Forbidden).
43
43
  #
44
- # NOTE: Devise helpers (current_user, authenticate_user!) are NOT available
45
- # because the engine controller inherits from ActionController::Base, not your
46
- # app's ApplicationController. Use `warden` directly instead.
47
- #
48
- # Devise/Warden example (recommended):
44
+ # Devise helpers (current_user, user_signed_in?, authenticate_user!) work:
45
+ # Devise adds them to ActionController::Base, which the engine's controller
46
+ # inherits. Methods defined only in your app's ApplicationController do not:
47
+ # the lambda raises and access is denied (logged as "authenticate_with lambda
48
+ # raised").
49
+ #
50
+ # Devise examples:
51
+ # config.authenticate_with = -> { current_user&.admin? }
49
52
  # config.authenticate_with = -> { warden.authenticated? }
50
53
  #
51
54
  # Warden with redirect to login:
@@ -408,6 +411,12 @@ RailsErrorDashboard.configure do |config|
408
411
  config.enable_system_health = false
409
412
 
410
413
  <% end -%>
414
+ # Occurrence Context History (NEW in 0.16.0)
415
+ # Each occurrence keeps its own breadcrumbs, system health and variables, so
416
+ # any of an error's recent occurrences can be opened, not only the latest.
417
+ # Kept for the newest N occurrences per error; 0 stops storing them.
418
+ config.occurrence_context_limit = 25
419
+
411
420
  <% if @enable_swallowed_exceptions -%>
412
421
  # Swallowed Exception Detection - ENABLED
413
422
  # Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
@@ -23,6 +23,7 @@ module RailsErrorDashboard
23
23
 
24
24
  muted_count = 0
25
25
  failed_ids = []
26
+ failures = {}
26
27
  muted_errors = []
27
28
 
28
29
  errors.each do |error|
@@ -33,11 +34,18 @@ module RailsErrorDashboard
33
34
  muted_by: @muted_by,
34
35
  muted_reason: @reason
35
36
  )
37
+ # The comment a single mute writes, so the reason shows in the thread
38
+ if @reason.present?
39
+ error.comments.create!(author_name: @muted_by || "System", body: "Muted notifications: #{@reason}")
40
+ end
36
41
  muted_count += 1
37
42
  muted_errors << error
38
43
  rescue => e
39
44
  failed_ids << error.id
40
- RailsErrorDashboard::Logger.error("Failed to mute error #{error.id}: #{e.message}")
45
+ failures[error.id] = "#{e.class}: #{e.message}"
46
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
47
+ # this is the only record of why the row failed
48
+ Rails.logger.warn("[RailsErrorDashboard] Failed to mute error #{error.id}: #{e.class}: #{e.message}")
41
49
  end
42
50
  end
43
51
 
@@ -50,7 +58,8 @@ module RailsErrorDashboard
50
58
  count: muted_count,
51
59
  total: @error_ids.size,
52
60
  failed_ids: failed_ids,
53
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.mute", count: failed_ids.size) ]
61
+ failures: failures,
62
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.mute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
54
63
  }
55
64
  rescue => e
56
65
  RailsErrorDashboard::Logger.error("Batch mute failed: #{e.message}")
@@ -24,6 +24,7 @@ module RailsErrorDashboard
24
24
 
25
25
  resolved_count = 0
26
26
  failed_ids = []
27
+ failures = {}
27
28
 
28
29
  resolved_errors = []
29
30
 
@@ -40,7 +41,10 @@ module RailsErrorDashboard
40
41
  resolved_errors << error
41
42
  rescue => e
42
43
  failed_ids << error.id
43
- RailsErrorDashboard::Logger.error("Failed to resolve error #{error.id}: #{e.message}")
44
+ failures[error.id] = "#{e.class}: #{e.message}"
45
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
46
+ # this is the only record of why the row failed
47
+ Rails.logger.warn("[RailsErrorDashboard] Failed to resolve error #{error.id}: #{e.class}: #{e.message}")
44
48
  end
45
49
  end
46
50
 
@@ -62,7 +66,8 @@ module RailsErrorDashboard
62
66
  count: resolved_count,
63
67
  total: @error_ids.size,
64
68
  failed_ids: failed_ids,
65
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.resolve", count: failed_ids.size) ]
69
+ failures: failures,
70
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.resolve", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
66
71
  }
67
72
  rescue => e
68
73
  RailsErrorDashboard::Logger.error("Batch resolve failed: #{e.message}")
@@ -21,6 +21,7 @@ module RailsErrorDashboard
21
21
 
22
22
  unmuted_count = 0
23
23
  failed_ids = []
24
+ failures = {}
24
25
  unmuted_errors = []
25
26
 
26
27
  errors.each do |error|
@@ -35,7 +36,10 @@ module RailsErrorDashboard
35
36
  unmuted_errors << error
36
37
  rescue => e
37
38
  failed_ids << error.id
38
- RailsErrorDashboard::Logger.error("Failed to unmute error #{error.id}: #{e.message}")
39
+ failures[error.id] = "#{e.class}: #{e.message}"
40
+ # Rails.logger, not RED's logger: log_level defaults to :silent, and
41
+ # this is the only record of why the row failed
42
+ Rails.logger.warn("[RailsErrorDashboard] Failed to unmute error #{error.id}: #{e.class}: #{e.message}")
39
43
  end
40
44
  end
41
45
 
@@ -48,7 +52,8 @@ module RailsErrorDashboard
48
52
  count: unmuted_count,
49
53
  total: @error_ids.size,
50
54
  failed_ids: failed_ids,
51
- errors: failed_ids.empty? ? [] : [ red_tp("red.commands.batch_failed.unmute", count: failed_ids.size) ]
55
+ failures: failures,
56
+ errors: failed_ids.empty? ? [] : [ "#{red_tp("red.commands.batch_failed.unmute", count: failed_ids.size)} (#{failed_ids.map { |id| "##{id}" }.join(", ")})" ]
52
57
  }
53
58
  rescue => e
54
59
  RailsErrorDashboard::Logger.error("Batch unmute failed: #{e.message}")
@@ -230,6 +230,12 @@ module RailsErrorDashboard
230
230
  return {} unless incoming.include?("\n") || incoming.length > stored.length
231
231
 
232
232
  upgrade = { backtrace: @attributes[:backtrace] }
233
+ # The source goes with the backtrace it describes: a client's backtrace
234
+ # replacing a server one must make the row client, and the reverse. A
235
+ # caller that names none is RED's own server-side code.
236
+ if ErrorLog.column_names.include?("backtrace_source")
237
+ upgrade[:backtrace_source] = @attributes[:backtrace_source].presence || "server"
238
+ end
233
239
  # The row is no longer a reconstructed exemplar: it now carries a real
234
240
  # stack from a real capture, so it must stop describing itself as
235
241
  # "minimal".
@@ -91,10 +91,8 @@ module RailsErrorDashboard
91
91
  end
92
92
 
93
93
  def current_application_id
94
- app_name = RailsErrorDashboard.configuration.application_name
95
- return nil unless app_name.present?
96
-
97
- Application.find_by(name: app_name)&.id
94
+ # The name capture files errors under, so these rows join that app
95
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
98
96
  rescue => e
99
97
  nil
100
98
  end
@@ -83,10 +83,8 @@ module RailsErrorDashboard
83
83
  end
84
84
 
85
85
  def current_application_id
86
- app_name = RailsErrorDashboard.configuration.application_name
87
- return nil unless app_name.present?
88
-
89
- Application.find_by(name: app_name)&.id
86
+ # The name capture files errors under, so these rows join that app
87
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
90
88
  rescue => e
91
89
  nil
92
90
  end
@@ -391,6 +391,12 @@ module RailsErrorDashboard
391
391
  if ErrorLog.column_names.include?("context_captured_at")
392
392
  create_attrs[:context_captured_at] = create_attrs[:occurred_at]
393
393
  end
394
+ # The backtrace above is the exemplar's first frame, so it is a
395
+ # client's when the counted event was a client report. An entry from
396
+ # a release that did not record the source reads as server.
397
+ if ErrorLog.column_names.include?("backtrace_source")
398
+ create_attrs[:backtrace_source] = entry["capture_source"] == "client" ? "client" : "server"
399
+ end
394
400
  begin
395
401
  # requires_new opens a SAVEPOINT: on PostgreSQL a failed INSERT aborts
396
402
  # its transaction, and every later statement -- including the recovery
@@ -498,12 +504,7 @@ module RailsErrorDashboard
498
504
  end
499
505
 
500
506
  def resolve_application
501
- # Same chain LogError uses — app name is process-global
502
- app_name = RailsErrorDashboard.configuration.application_name ||
503
- ENV["APPLICATION_NAME"] ||
504
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
505
- "Rails Application"
506
- Application.find_or_create_by_name(app_name)
507
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
507
508
  end
508
509
 
509
510
  # Record the interval whose per-event timing was lost.
@@ -90,10 +90,8 @@ module RailsErrorDashboard
90
90
  end
91
91
 
92
92
  def current_application_id
93
- app_name = RailsErrorDashboard.configuration.application_name
94
- return nil unless app_name.present?
95
-
96
- Application.find_by(name: app_name)&.id
93
+ # The name capture files errors under, so these rows join that app
94
+ Application.find_by(name: RailsErrorDashboard.resolved_application_name)&.id
97
95
  rescue => e
98
96
  nil
99
97
  end
@@ -513,6 +513,13 @@ module RailsErrorDashboard
513
513
  attributes[:environment] = resolve_environment
514
514
  end
515
515
 
516
+ # Where the backtrace this row is created with came from. Not part of
517
+ # the fingerprint: a client report and a server capture of one error
518
+ # stay one group.
519
+ if ErrorLog.column_names.include?("backtrace_source")
520
+ attributes[:backtrace_source] = capture_source
521
+ end
522
+
516
523
  # Neutralise invalid bytes BEFORE filtering: the filter runs regexes,
517
524
  # which raise on an invalid string, and PostgreSQL rejects the INSERT.
518
525
  attributes = Services::EncodingSanitizer.scrub_deep(attributes)
@@ -654,8 +661,12 @@ module RailsErrorDashboard
654
661
  occurrence_columns = ErrorOccurrence.column_names
655
662
  occurrence_attrs[:app_version] = attributes[:app_version] if occurrence_columns.include?("app_version")
656
663
  occurrence_attrs[:git_sha] = attributes[:git_sha] if occurrence_columns.include?("git_sha")
664
+ occurrence_attrs[:capture_source] = capture_source if occurrence_columns.include?("capture_source")
657
665
  occurrence_attrs = Services::EncodingSanitizer.scrub_deep(occurrence_attrs)
658
- ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
666
+ occurrence = ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
667
+ # This occurrence's own copy of the context the row just got. Never
668
+ # raises, so a failure here is never logged as the occurrence's.
669
+ RecordOccurrenceContext.call(occurrence, attributes) if occurrence.persisted?
659
670
  rescue => e
660
671
  RailsErrorDashboard::Logger.error("Failed to create error occurrence: #{e.message}")
661
672
  end
@@ -799,12 +810,7 @@ module RailsErrorDashboard
799
810
  end
800
811
 
801
812
  def find_or_create_application
802
- app_name = RailsErrorDashboard.configuration.application_name ||
803
- ENV["APPLICATION_NAME"] ||
804
- (defined?(Rails) && Rails.application.class.module_parent_name) ||
805
- "Rails Application"
806
-
807
- Application.find_or_create_by_name(app_name)
813
+ Application.find_or_create_by_name(RailsErrorDashboard.resolved_application_name)
808
814
  rescue => e
809
815
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Failed to find/create application: #{e.message}")
810
816
  # Fallback: try to find any application or create default
@@ -1006,6 +1012,12 @@ module RailsErrorDashboard
1006
1012
 
1007
1013
  # Symbol or string key: the async job round-trips the context through the
1008
1014
  # queue serializer, which turns symbol keys into strings.
1015
+ # "client" for a ManualErrorReporter report, "server" for every other
1016
+ # capture. Only the exact marker ManualErrorReporter sets counts.
1017
+ def capture_source
1018
+ context_value(:_capture_source) == "client" ? "client" : "server"
1019
+ end
1020
+
1009
1021
  def context_value(key)
1010
1022
  return nil unless @context.is_a?(Hash)
1011
1023
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Keeps one occurrence's moment-of-failure context, then trims the error's
6
+ # stored contexts to the newest config.occurrence_context_limit.
7
+ #
8
+ # The values are the JSON strings LogError has just written to the error
9
+ # row (breadcrumbs, system health, local and instance variables), so an
10
+ # occurrence holds exactly what the row would, filtered the same way. A
11
+ # capture with none of them (a storm-protection :lite capture, or every
12
+ # feature off) stores nothing.
13
+ #
14
+ # Runs inside the capture: it never raises.
15
+ class RecordOccurrenceContext
16
+ PIECES = %i[breadcrumbs system_health local_variables instance_variables].freeze
17
+
18
+ # @param occurrence [ErrorOccurrence] the occurrence just created
19
+ # @param attributes [Hash] the attributes LogError wrote to the error row
20
+ def self.call(occurrence, attributes)
21
+ new(occurrence, attributes).call
22
+ end
23
+
24
+ def initialize(occurrence, attributes)
25
+ @occurrence = occurrence
26
+ @attributes = attributes
27
+ end
28
+
29
+ def call
30
+ limit = RailsErrorDashboard.configuration.occurrence_context_limit.to_i
31
+ return if limit <= 0
32
+
33
+ values = PIECES.index_with { |piece| @attributes[piece].presence }.compact
34
+ return if values.empty?
35
+
36
+ # An app that updated the gem but has not migrated yet
37
+ return unless ErrorOccurrenceContext.table_exists?
38
+
39
+ ErrorOccurrenceContext.create!(
40
+ values.merge(error_occurrence_id: @occurrence.id, error_log_id: @occurrence.error_log_id, created_at: Time.current)
41
+ )
42
+ trim(limit)
43
+ rescue => e
44
+ RailsErrorDashboard::Logger.error(
45
+ "[RailsErrorDashboard] Failed to record occurrence context: #{e.class}: #{e.message}"
46
+ )
47
+ nil
48
+ end
49
+
50
+ private
51
+
52
+ # Everything past the newest `limit`, normally the one the insert pushed out
53
+ def trim(limit)
54
+ stale = ErrorOccurrenceContext.where(error_log_id: @occurrence.error_log_id)
55
+ .order(id: :desc).offset(limit).pluck(:id)
56
+ ErrorOccurrenceContext.where(id: stale).delete_all if stale.any?
57
+ end
58
+ end
59
+ end
60
+ end