rails_error_dashboard 0.11.9 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
  6. data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
  8. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
  9. data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
  10. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
  11. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
  12. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
  13. data/app/models/rails_error_dashboard/error_log.rb +47 -0
  14. data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
  15. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
  16. data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
  17. data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
  18. data/config/locales/de.yml +9 -0
  19. data/config/locales/en.yml +35 -0
  20. data/config/locales/es.yml +9 -0
  21. data/config/locales/fr.yml +10 -1
  22. data/config/locales/it.yml +9 -0
  23. data/config/locales/ja.yml +9 -0
  24. data/config/locales/pl.yml +9 -0
  25. data/config/locales/pt-BR.yml +9 -0
  26. data/config/locales/ru.yml +9 -0
  27. data/config/locales/uk.yml +9 -0
  28. data/config/locales/zh-CN.yml +9 -0
  29. data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
  30. data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
  31. data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
  32. data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
  33. data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
  34. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
  35. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
  36. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
  37. data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
  38. data/lib/rails_error_dashboard/configuration.rb +4 -3
  39. data/lib/rails_error_dashboard/engine.rb +28 -0
  40. data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
  41. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
  42. data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
  43. data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
  44. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
  45. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
  46. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
  47. data/lib/rails_error_dashboard/version.rb +1 -1
  48. metadata +9 -3
@@ -7,6 +7,15 @@
7
7
  </span>
8
8
  </div>
9
9
 
10
+ <%# Zero errors and a failed query look identical on a dashboard that
11
+ rescues to zeros. Say which one this is. %>
12
+ <% if @stats[:data_unavailable] %>
13
+ <div class="alert-warning" style="display: flex; align-items: center; gap: 10px; padding: 10px var(--space-5); background: var(--status-warning-bg); border-radius: var(--radius-md); border: 1px solid var(--status-warning); margin-bottom: var(--space-6); font-size: 13px; color: var(--status-warning);">
14
+ <i class="bi bi-exclamation-triangle"></i>
15
+ <strong><%= red_t("red.errors.overview_page.data_unavailable") %></strong>
16
+ </div>
17
+ <% end %>
18
+
10
19
  <!-- Spike / Critical Alerts Banner -->
11
20
  <% if @critical_alerts.any? %>
12
21
  <%
@@ -37,7 +46,7 @@
37
46
  <div class="card stat-card" style="border-left: 3px solid var(--status-critical); padding: var(--space-5) var(--space-6);">
38
47
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.error_rate") %></div>
39
48
  <div style="display: flex; align-items: baseline; gap: 8px;">
40
- <span class="stat-value" style="color: var(--status-critical);"><%= red_t("red.errors.pages.percent", value: @stats[:error_rate]) %></span>
49
+ <span class="stat-value" style="color: var(--status-critical);"><%= red_t("red.errors.overview_page.rate_per_hour", value: @stats[:error_rate]) %></span>
41
50
  <% if @stats[:trend_percentage].present? && @stats[:trend_percentage] != 0 %>
42
51
  <span style="display: inline-flex; align-items: center; gap: 2px; font-size: 12px; font-weight: 600; color: <%= @stats[:trend_percentage] > 0 ? 'var(--status-critical)' : 'var(--status-success)' %>;">
43
52
  <i class="bi <%= @stats[:trend_percentage] > 0 ? 'bi-arrow-up-right' : 'bi-arrow-down-right' %>" style="font-size: 10px;"></i>
@@ -93,6 +102,9 @@
93
102
  <!-- Affected Users -->
94
103
  <div class="card stat-card" style="padding: var(--space-5) var(--space-6);">
95
104
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.errors.overview_page.affected_users") %></div>
105
+ <% if @stats[:affected_users_incomplete] %>
106
+ <small style="display: block; color: var(--text-tertiary); margin-bottom: 4px;"><%= red_t("red.errors.overview_page.affected_users_incomplete") %></small>
107
+ <% end %>
96
108
  <div style="display: flex; align-items: baseline; gap: 8px;">
97
109
  <span class="stat-value"><%= @stats[:affected_users_today] %></span>
98
110
  <% if @stats[:affected_users_change] != 0 %>
@@ -577,6 +577,12 @@ de:
577
577
  copy_curl_title: curl-Befehl in die Zwischenablage kopieren
578
578
  copy_rspec: Als RSpec kopieren
579
579
  copy_rspec_title: RSpec-Request-Spec in die Zwischenablage kopieren
580
+ snapshot: 'Momentaufnahme:'
581
+ snapshot_captured_html: Erfasst %{time}
582
+ snapshot_stale: aus einem früheren Auftreten — spätere lieferten keinen Kontext
583
+ snapshot_fidelity_lite: reduzierte Erfassung (der Storm-Schutz hat die Kontext-Payloads verworfen)
584
+ snapshot_fidelity_minimal: während eines Storms gezählt — es wurde kein Kontext erfasst
585
+ snapshot_unknown: erfasst, bevor RED die Herkunft von Momentaufnahmen aufzeichnete
580
586
  similar_errors:
581
587
  title: Ähnliche Fehler
582
588
  badge: Unscharfer Abgleich
@@ -930,6 +936,9 @@ de:
930
936
  alert_view: Ansehen
931
937
  error_rate: Fehlerrate
932
938
  error_rate_hint: Fehler pro Stunde heute
939
+ rate_per_hour: "%{value}/Std."
940
+ affected_users_incomplete: mindestens — der Storm-Schutz hat Detaildaten pro Ereignis verworfen
941
+ data_unavailable: Statistiken sind derzeit nicht verfügbar — die Zahlen unten sind keine Messung.
933
942
  unresolved: Offen
934
943
  unresolved_hint: Behebung ausstehend
935
944
  resolution_rate: Behebungsquote
@@ -802,6 +802,26 @@ en:
802
802
  copy_rspec: "Copy as RSpec"
803
803
  copy_rspec_title: "Copy RSpec request spec to clipboard"
804
804
 
805
+ # Provenance for the diagnostic snapshot this page shows.
806
+ #
807
+ # An ErrorLog row is a GROUP, but its request context, breadcrumbs,
808
+ # locals and health describe ONE moment of failure -- refreshed by each
809
+ # occurrence that carried them, and deliberately left alone by one that
810
+ # did not. Without these labels the page presented that collection as
811
+ # "the error's context" with no way to tell which event supplied it.
812
+ snapshot: "Snapshot:"
813
+ # %{time} is a <span class="local-time"> element, hence _html.
814
+ snapshot_captured_html: "Captured %{time}"
815
+ # Shown when the snapshot predates the most recent occurrence: the
816
+ # counts moved on, this evidence did not.
817
+ snapshot_stale: "from an earlier occurrence — later ones carried no context"
818
+ # Capture fidelity. "minimal" rows are reconstructed by the storm flush
819
+ # from a counted-only event: the group is real and its count is exact,
820
+ # but no backtrace or context was ever captured for it.
821
+ snapshot_fidelity_lite: "reduced capture (storm protection shed the context payloads)"
822
+ snapshot_fidelity_minimal: "counted during a storm — no context was captured"
823
+ snapshot_unknown: "captured before RED recorded snapshot provenance"
824
+
805
825
  similar_errors:
806
826
  title: "Similar Errors"
807
827
  badge: "Fuzzy Matching"
@@ -1058,6 +1078,8 @@ en:
1058
1078
  # Each is a bare noun phrase standing over a number, not a sentence
1059
1079
  # fragment to be joined to one.
1060
1080
  stats:
1081
+ # These count EVENTS (every occurrence), not error groups. A single
1082
+ # group that happened 500 times contributes 500 here.
1061
1083
  today: "Today"
1062
1084
  this_week: "This Week"
1063
1085
  unresolved: "Unresolved"
@@ -1294,6 +1316,19 @@ en:
1294
1316
  alert_view: "View"
1295
1317
  error_rate: "Error Rate"
1296
1318
  error_rate_hint: "Errors per hour today"
1319
+ # The error rate is a RATE, not a percentage. It used to render with a
1320
+ # "%" against a scale that called one error per hour "1%", capped at
1321
+ # 100 -- so 4,000 errors/hour showed as "100%". There is no request
1322
+ # denominator to build a real failure percentage from, so the honest
1323
+ # figure is the rate itself. %{value} is the already-formatted number.
1324
+ rate_per_hour: "%{value}/hr"
1325
+ # Shown beside the affected-user figure when storm protection shed
1326
+ # per-event rows in the window: the count is a floor, not a total.
1327
+ # "storm" is RED's term and stays verbatim (see TRANSLATIONS.md).
1328
+ affected_users_incomplete: "at least — storm protection shed per-event detail"
1329
+ # Zero errors and "the dashboard could not read its data" are
1330
+ # different states. This banner says which one you are looking at.
1331
+ data_unavailable: "Statistics are currently unavailable — the figures below are not a measurement."
1297
1332
  unresolved: "Unresolved"
1298
1333
  unresolved_hint: "Pending resolution"
1299
1334
  resolution_rate: "Resolution Rate"
@@ -602,6 +602,12 @@ es:
602
602
  copy_curl_title: Copiar el comando curl al portapapeles
603
603
  copy_rspec: Copiar como RSpec
604
604
  copy_rspec_title: Copiar el request spec de RSpec al portapapeles
605
+ snapshot: 'Instantánea:'
606
+ snapshot_captured_html: Capturada %{time}
607
+ snapshot_stale: de una aparición anterior — las posteriores no aportaron contexto
608
+ snapshot_fidelity_lite: captura reducida (la protección ante storms descartó los payloads de contexto)
609
+ snapshot_fidelity_minimal: contabilizado durante un storm — no se capturó ningún contexto
610
+ snapshot_unknown: capturada antes de que RED registrara la procedencia de las instantáneas
605
611
  similar_errors:
606
612
  title: Errores similares
607
613
  badge: Coincidencia aproximada
@@ -960,6 +966,9 @@ es:
960
966
  alert_view: Ver
961
967
  error_rate: Tasa de error
962
968
  error_rate_hint: Errores por hora hoy
969
+ rate_per_hour: "%{value}/h"
970
+ affected_users_incomplete: al menos — la protección ante storms descartó el detalle por evento
971
+ data_unavailable: Las estadísticas no están disponibles por ahora — las cifras siguientes no son una medición.
963
972
  unresolved: Sin resolver
964
973
  unresolved_hint: Pendientes de resolución
965
974
  resolution_rate: Tasa de resolución
@@ -1,6 +1,6 @@
1
1
  # RED dashboard translations — fr
2
2
  #
3
- # MACHINE-TRANSLATED AND NOT REVIEWED BY A NATIVE SPEAKER.
3
+ # COMMUNITY-REVIEWED BY A NATIVE SPEAKER (v0.11.5, #201, issue #158).
4
4
  # See docs/guides/TRANSLATIONS.md. Corrections are welcome, and a one-key
5
5
  # pull request is a perfectly good pull request.
6
6
  #
@@ -601,6 +601,12 @@ fr:
601
601
  copy_curl_title: Copier la commande curl dans le presse-papiers
602
602
  copy_rspec: Copier en RSpec
603
603
  copy_rspec_title: Copier le request spec RSpec dans le presse-papiers
604
+ snapshot: 'Instantané :'
605
+ snapshot_captured_html: Capturé %{time}
606
+ snapshot_stale: issu d'une occurrence antérieure — les suivantes n'ont apporté aucun contexte
607
+ snapshot_fidelity_lite: capture réduite (la protection contre les storms a écarté les payloads de contexte)
608
+ snapshot_fidelity_minimal: comptabilisé pendant un storm — aucun contexte n'a été capturé
609
+ snapshot_unknown: capturé avant que RED n'enregistre la provenance des instantanés
604
610
  similar_errors:
605
611
  title: Erreurs similaires
606
612
  badge: Correspondance approximative
@@ -961,6 +967,9 @@ fr:
961
967
  alert_view: Voir
962
968
  error_rate: Taux d'erreur
963
969
  error_rate_hint: Erreurs par heure aujourd'hui
970
+ rate_per_hour: "%{value}/h"
971
+ affected_users_incomplete: au moins — la protection contre les storms a écarté le détail par événement
972
+ data_unavailable: Les statistiques sont indisponibles pour le moment — les chiffres ci-dessous ne sont pas une mesure.
964
973
  unresolved: Non résolues
965
974
  unresolved_hint: En attente de résolution
966
975
  resolution_rate: Taux de résolution
@@ -595,6 +595,12 @@ it:
595
595
  copy_curl_title: Copia il comando curl negli appunti
596
596
  copy_rspec: Copia come RSpec
597
597
  copy_rspec_title: Copia il request spec RSpec negli appunti
598
+ snapshot: 'Istantanea:'
599
+ snapshot_captured_html: Acquisita %{time}
600
+ snapshot_stale: da un'occorrenza precedente — quelle successive non hanno fornito contesto
601
+ snapshot_fidelity_lite: acquisizione ridotta (la protezione dagli storm ha scartato i payload di contesto)
602
+ snapshot_fidelity_minimal: conteggiato durante uno storm — non è stato acquisito alcun contesto
603
+ snapshot_unknown: acquisita prima che RED registrasse la provenienza delle istantanee
598
604
  similar_errors:
599
605
  title: Errori simili
600
606
  badge: Corrispondenza approssimata
@@ -952,6 +958,9 @@ it:
952
958
  alert_view: Vedi
953
959
  error_rate: Tasso di errore
954
960
  error_rate_hint: Errori all'ora oggi
961
+ rate_per_hour: "%{value}/h"
962
+ affected_users_incomplete: almeno — la protezione dagli storm ha scartato il dettaglio per evento
963
+ data_unavailable: Le statistiche non sono al momento disponibili — i valori seguenti non sono una misurazione.
955
964
  unresolved: Non risolti
956
965
  unresolved_hint: In attesa di risoluzione
957
966
  resolution_rate: Tasso di risoluzione
@@ -526,6 +526,12 @@ ja:
526
526
  copy_curl_title: curl コマンドをクリップボードにコピー
527
527
  copy_rspec: RSpec としてコピー
528
528
  copy_rspec_title: RSpec のリクエストスペックをクリップボードにコピー
529
+ snapshot: 'スナップショット:'
530
+ snapshot_captured_html: '%{time} に取得'
531
+ snapshot_stale: より前の発生から取得 — それ以降の発生はコンテキストを伴いませんでした
532
+ snapshot_fidelity_lite: 縮小された取得 (storm 保護によりコンテキストの payload が破棄されました)
533
+ snapshot_fidelity_minimal: storm 中にカウントされました — コンテキストは取得されていません
534
+ snapshot_unknown: RED がスナップショットの出所を記録する前に取得されました
529
535
  similar_errors:
530
536
  title: 類似エラー
531
537
  badge: あいまい一致
@@ -836,6 +842,9 @@ ja:
836
842
  alert_view: 表示
837
843
  error_rate: エラー率
838
844
  error_rate_hint: 本日の1時間あたりのエラー数
845
+ rate_per_hour: "%{value}/時"
846
+ affected_users_incomplete: 最少値 — storm 保護によりイベントごとの詳細が破棄されました
847
+ data_unavailable: 統計は現在利用できません — 以下の数値は計測値ではありません。
839
848
  unresolved: 未解決
840
849
  unresolved_hint: 解決待ち
841
850
  resolution_rate: 解決率
@@ -605,6 +605,12 @@ pl:
605
605
  copy_curl_title: Skopiuj polecenie curl do schowka
606
606
  copy_rspec: Skopiuj jako RSpec
607
607
  copy_rspec_title: Skopiuj request spec RSpec do schowka
608
+ snapshot: 'Migawka:'
609
+ snapshot_captured_html: Przechwycono %{time}
610
+ snapshot_stale: z wcześniejszego wystąpienia — późniejsze nie przyniosły kontekstu
611
+ snapshot_fidelity_lite: ograniczone przechwytywanie (ochrona przed storm odrzuciła payloady kontekstu)
612
+ snapshot_fidelity_minimal: zliczone podczas storm — nie przechwycono żadnego kontekstu
613
+ snapshot_unknown: przechwycono, zanim RED zaczął zapisywać pochodzenie migawek
608
614
  similar_errors:
609
615
  title: Podobne błędy
610
616
  badge: Dopasowanie rozmyte
@@ -968,6 +974,9 @@ pl:
968
974
  alert_view: Zobacz
969
975
  error_rate: Wskaźnik błędów
970
976
  error_rate_hint: Błędów na godzinę dzisiaj
977
+ rate_per_hour: "%{value}/godz."
978
+ affected_users_incomplete: co najmniej — ochrona przed storm odrzuciła szczegóły poszczególnych zdarzeń
979
+ data_unavailable: Statystyki są obecnie niedostępne — poniższe liczby nie są pomiarem.
971
980
  unresolved: Nierozwiązane
972
981
  unresolved_hint: Oczekują na rozwiązanie
973
982
  resolution_rate: Wskaźnik rozwiązań
@@ -600,6 +600,12 @@ pt-BR:
600
600
  copy_curl_title: Copiar o comando curl para a área de transferência
601
601
  copy_rspec: Copiar como RSpec
602
602
  copy_rspec_title: Copiar o request spec do RSpec para a área de transferência
603
+ snapshot: 'Instantâneo:'
604
+ snapshot_captured_html: Capturado %{time}
605
+ snapshot_stale: de uma ocorrência anterior — as posteriores não trouxeram contexto
606
+ snapshot_fidelity_lite: captura reduzida (a proteção contra storms descartou os payloads de contexto)
607
+ snapshot_fidelity_minimal: contabilizado durante um storm — nenhum contexto foi capturado
608
+ snapshot_unknown: capturado antes de o RED registrar a procedência dos instantâneos
603
609
  similar_errors:
604
610
  title: Erros semelhantes
605
611
  badge: Correspondência aproximada
@@ -956,6 +962,9 @@ pt-BR:
956
962
  alert_view: Ver
957
963
  error_rate: Taxa de erro
958
964
  error_rate_hint: Erros por hora hoje
965
+ rate_per_hour: "%{value}/h"
966
+ affected_users_incomplete: no mínimo — a proteção contra storms descartou o detalhe por evento
967
+ data_unavailable: As estatísticas estão indisponíveis no momento — os números abaixo não são uma medição.
959
968
  unresolved: Não resolvidos
960
969
  unresolved_hint: Aguardando resolução
961
970
  resolution_rate: Taxa de resolução
@@ -608,6 +608,12 @@ ru:
608
608
  copy_curl_title: Скопировать команду curl в буфер обмена
609
609
  copy_rspec: Скопировать как RSpec
610
610
  copy_rspec_title: Скопировать request-спеку RSpec в буфер обмена
611
+ snapshot: 'Снимок:'
612
+ snapshot_captured_html: 'Получен %{time}'
613
+ snapshot_stale: из более раннего появления — последующие не содержали контекста
614
+ snapshot_fidelity_lite: сокращённый сбор (защита от storm отбросила payload контекста)
615
+ snapshot_fidelity_minimal: учтено во время storm — контекст не собирался
616
+ snapshot_unknown: получен до того, как RED начал записывать происхождение снимков
611
617
  similar_errors:
612
618
  title: Похожие ошибки
613
619
  badge: Нечёткое совпадение
@@ -971,6 +977,9 @@ ru:
971
977
  alert_view: Открыть
972
978
  error_rate: Частота ошибок
973
979
  error_rate_hint: Ошибок в час за сегодня
980
+ rate_per_hour: "%{value}/ч"
981
+ affected_users_incomplete: не менее — защита от storm отбросила детализацию по событиям
982
+ data_unavailable: Статистика сейчас недоступна — приведённые ниже числа не являются измерением.
974
983
  unresolved: Не решено
975
984
  unresolved_hint: Ожидают решения
976
985
  resolution_rate: Доля решённых
@@ -606,6 +606,12 @@ uk:
606
606
  copy_curl_title: Скопіювати команду curl у буфер обміну
607
607
  copy_rspec: Скопіювати як RSpec
608
608
  copy_rspec_title: Скопіювати request spec RSpec у буфер обміну
609
+ snapshot: 'Знімок:'
610
+ snapshot_captured_html: 'Отримано %{time}'
611
+ snapshot_stale: з ранішого випадку — подальші не містили контексту
612
+ snapshot_fidelity_lite: скорочений збір (захист від storm відкинув payload контексту)
613
+ snapshot_fidelity_minimal: враховано під час storm — контекст не збирався
614
+ snapshot_unknown: отримано до того, як RED почав записувати походження знімків
609
615
  similar_errors:
610
616
  title: Схожі помилки
611
617
  badge: Нечітке зіставлення
@@ -968,6 +974,9 @@ uk:
968
974
  alert_view: Переглянути
969
975
  error_rate: Частота помилок
970
976
  error_rate_hint: Помилок за годину сьогодні
977
+ rate_per_hour: "%{value}/год"
978
+ affected_users_incomplete: щонайменше — захист від storm відкинув деталізацію за подіями
979
+ data_unavailable: Статистика зараз недоступна — наведені нижче числа не є вимірюванням.
971
980
  unresolved: Не вирішено
972
981
  unresolved_hint: Очікують вирішення
973
982
  resolution_rate: Частка вирішених
@@ -523,6 +523,12 @@ zh-CN:
523
523
  copy_curl_title: 复制 curl 命令到剪贴板
524
524
  copy_rspec: 复制为 RSpec
525
525
  copy_rspec_title: 复制 RSpec request spec 到剪贴板
526
+ snapshot: '快照:'
527
+ snapshot_captured_html: 捕获于 %{time}
528
+ snapshot_stale: 来自更早的一次发生 — 之后的发生未携带上下文
529
+ snapshot_fidelity_lite: 已精简的捕获(storm 保护丢弃了上下文 payload)
530
+ snapshot_fidelity_minimal: 在 storm 期间计数 — 未捕获任何上下文
531
+ snapshot_unknown: 在 RED 记录快照来源之前捕获
526
532
  similar_errors:
527
533
  title: 相似错误
528
534
  badge: 模糊匹配
@@ -832,6 +838,9 @@ zh-CN:
832
838
  alert_view: 查看
833
839
  error_rate: 错误率
834
840
  error_rate_hint: 今日每小时错误数
841
+ rate_per_hour: "%{value}/小时"
842
+ affected_users_incomplete: 至少 — storm 保护丢弃了逐事件的明细
843
+ data_unavailable: 统计数据当前不可用 — 下方数字并非测量结果。
835
844
  unresolved: 未解决
836
845
  unresolved_hint: 等待处理
837
846
  resolution_rate: 解决率
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Give an error group a database-enforced identity.
4
+ #
5
+ # FindOrIncrementError locks the row it finds, which makes concurrent
6
+ # *increments* safe. Creation had no such protection: two connections that both
7
+ # miss the unresolved lookup for one fingerprint both INSERT, and the
8
+ # RecordNotUnique retry branch below that miss was unreachable because no
9
+ # unique constraint existed to violate. Two rows, each occurrence_count 1,
10
+ # splitting counts, workflow and notifications at the exact moment a new fault
11
+ # fans out across a fleet.
12
+ #
13
+ # The identity is (application_id, error_hash, environment, group_window)
14
+ # restricted to unresolved rows:
15
+ #
16
+ # * environment is a MATCH dimension — the same error in staging and in
17
+ # production is legitimately two rows, so it belongs in the key.
18
+ # * group_window is why this is not a blanket unique index. RED deliberately
19
+ # opens a NEW unresolved group once the previous one's occurred_at falls
20
+ # outside its 24 h window, so (application_id, error_hash, environment)
21
+ # alone forbids intended behaviour. group_window is an immutable bucket
22
+ # stamped at creation, so two racing creates (milliseconds apart) share a
23
+ # bucket and collide, while a create after the window rolls over gets a
24
+ # different bucket and is allowed.
25
+ # * both are wrapped in COALESCE. In SQL NULL != NULL, so a plain index over
26
+ # nullable columns would NOT constrain legacy rows: every NULL-environment
27
+ # row (pre-0.11.0) and every NULL-group_window row (pre-this-migration) is
28
+ # distinct from every other, and the duplicates this index exists to
29
+ # prevent slip straight through the hole.
30
+ # * WHERE resolved = false keeps reopen semantics intact. A resolved row and
31
+ # its unresolved successor coexist, and several resolved rows for one
32
+ # fingerprint are ordinary history.
33
+ #
34
+ # The index is a BACKSTOP, not the grouping mechanism. The 24 h lookup still
35
+ # runs first and catches the ordinary near-boundary case; the index only bites
36
+ # when two creates genuinely race, which is why a coarse bucket is sufficient.
37
+ #
38
+ # MySQL has no partial (filtered) indexes, so the index is skipped there and
39
+ # the pre-existing RecordNotUnique retry simply stays dormant, exactly as it is
40
+ # today. That is a real gap, documented rather than papered over. The column is
41
+ # still added on MySQL so the model behaves identically on every adapter.
42
+ class AddGroupIdentityUniqueIndexToErrorLogs < ActiveRecord::Migration[7.0]
43
+ # CREATE INDEX CONCURRENTLY cannot run inside a transaction on PostgreSQL.
44
+ disable_ddl_transaction!
45
+
46
+ TABLE = :rails_error_dashboard_error_logs
47
+ INDEX = "index_error_logs_on_group_identity"
48
+ # Byte-identical to the expression in spec/dummy/db/schema.rb. SQLite echoes
49
+ # an expression index back verbatim and bin/check-schema-parity compares the
50
+ # echoed text, so these two spellings must not drift (note: no space after
51
+ # the comma inside either COALESCE).
52
+ EXPRESSION = "application_id, error_hash, COALESCE(environment,''), COALESCE(group_window,'')"
53
+
54
+ def up
55
+ return unless table_exists?(TABLE)
56
+
57
+ unless column_exists?(TABLE, :group_window)
58
+ # 10 chars: an ISO date bucket ("2026-09-15"). Short enough to keep the
59
+ # composite index cheap under MySQL's utf8mb4 key limit.
60
+ add_column TABLE, :group_window, :string, limit: 10
61
+ end
62
+
63
+ # Existing rows predate the column. Stamp each unresolved row from its own
64
+ # occurred_at so the bucket means the same thing for history as it does for
65
+ # new rows; anything that still collides after that is a genuine duplicate
66
+ # and is merged below.
67
+ backfill_group_window!
68
+
69
+ return if mysql? # no partial indexes
70
+ return if index_name_exists?(TABLE, INDEX)
71
+
72
+ # A host upgrading may already hold duplicates created by the very race
73
+ # this index prevents. Building the index over them would fail, so merge
74
+ # them first: the survivor keeps the summed occurrence_count and the widest
75
+ # time range, and the losers are deleted after their occurrence rows are
76
+ # re-pointed at the survivor.
77
+ deduplicate_existing_groups!
78
+
79
+ if postgresql?
80
+ add_index TABLE, EXPRESSION, name: INDEX, unique: true,
81
+ where: "resolved = false", algorithm: :concurrently
82
+ else
83
+ add_index TABLE, EXPRESSION, name: INDEX, unique: true, where: "resolved = false"
84
+ end
85
+ end
86
+
87
+ def down
88
+ return unless table_exists?(TABLE)
89
+
90
+ # Dropping the index and the column is reversible; merging the duplicate
91
+ # groups it required is not — the losing rows and their original counts
92
+ # are gone.
93
+ raise ActiveRecord::IrreversibleMigration,
94
+ "this migration merges duplicate error groups before creating #{INDEX}; the pre-merge rows cannot be restored"
95
+ end
96
+
97
+ private
98
+
99
+ # Only unresolved rows need a bucket: the index covers `resolved = false`
100
+ # only, and leaving resolved history NULL keeps the backfill cheap on a big
101
+ # table.
102
+ def backfill_group_window!
103
+ quoted = connection.quote_column_name("group_window")
104
+ connection.update(<<~SQL)
105
+ UPDATE #{connection.quote_table_name(TABLE.to_s)}
106
+ SET #{quoted} = #{window_expression}
107
+ WHERE #{quoted} IS NULL
108
+ AND resolved = #{quoted_false}
109
+ SQL
110
+ end
111
+
112
+ # The same value ErrorLog#set_group_window computes in Ruby: occurred_at as
113
+ # a UTC ISO date.
114
+ def window_expression
115
+ if postgresql?
116
+ "to_char(occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD')"
117
+ elsif mysql?
118
+ "DATE_FORMAT(CONVERT_TZ(occurred_at, '+00:00', '+00:00'), '%Y-%m-%d')"
119
+ else
120
+ "strftime('%Y-%m-%d', occurred_at)"
121
+ end
122
+ end
123
+
124
+ def deduplicate_existing_groups!
125
+ duplicate_keys.each do |application_id, error_hash, env_key, window_key|
126
+ # '' is the COALESCE stand-in for NULL (see duplicate_keys).
127
+ environment = env_key.presence
128
+ group_window = window_key.presence
129
+
130
+ scope = ErrorLogRow.where(application_id: application_id, error_hash: error_hash, resolved: false)
131
+ scope = environment.nil? ? scope.where(environment: nil) : scope.where(environment: environment)
132
+ scope = group_window.nil? ? scope.where(group_window: nil) : scope.where(group_window: group_window)
133
+
134
+ rows = scope.order(:id).to_a
135
+ next if rows.size < 2
136
+
137
+ survivor = rows.first
138
+ losers = rows[1..]
139
+
140
+ total = rows.sum { |r| r.occurrence_count.to_i }
141
+ first_seen = rows.filter_map { |r| r.occurred_at }.min
142
+ last_seen = rows.filter_map { |r| r.last_seen_at || r.occurred_at }.max
143
+
144
+ if occurrences_table?
145
+ OccurrenceRow.where(error_log_id: losers.map(&:id)).update_all(error_log_id: survivor.id)
146
+ end
147
+
148
+ updates = { occurrence_count: total }
149
+ updates[:occurred_at] = first_seen if first_seen
150
+ updates[:last_seen_at] = last_seen if last_seen
151
+ ErrorLogRow.where(id: survivor.id).update_all(updates)
152
+ ErrorLogRow.where(id: losers.map(&:id)).delete_all
153
+
154
+ say "merged #{losers.size} duplicate group(s) into error log #{survivor.id}", true
155
+ end
156
+ end
157
+
158
+ # The identity tuples that currently have more than one unresolved row.
159
+ # COALESCE mirrors the index expression, so a set of NULL-environment rows
160
+ # counts as one group rather than as N distinct ones.
161
+ def duplicate_keys
162
+ # PostgreSQL requires every selected column to appear in GROUP BY or an
163
+ # aggregate, so the COALESCE expressions are selected rather than the bare
164
+ # columns (SQLite tolerates the bare form; PG raises GroupingError).
165
+ # '' therefore means NULL here, which deduplicate_existing_groups! maps
166
+ # back when it scopes each group.
167
+ connection.select_rows(<<~SQL)
168
+ SELECT application_id,
169
+ error_hash,
170
+ COALESCE(environment, '') AS env_key,
171
+ COALESCE(group_window, '') AS window_key
172
+ FROM #{connection.quote_table_name(TABLE.to_s)}
173
+ WHERE resolved = #{quoted_false}
174
+ GROUP BY application_id, error_hash, COALESCE(environment, ''), COALESCE(group_window, '')
175
+ HAVING COUNT(*) > 1
176
+ SQL
177
+ end
178
+
179
+ def quoted_false
180
+ postgresql? ? "false" : "0"
181
+ end
182
+
183
+ def occurrences_table?
184
+ table_exists?(:rails_error_dashboard_error_occurrences)
185
+ end
186
+
187
+ def postgresql?
188
+ connection.adapter_name.downcase == "postgresql"
189
+ end
190
+
191
+ def mysql?
192
+ connection.adapter_name.downcase.match?(/mysql|trilogy/)
193
+ end
194
+
195
+ # Bare AR classes: the gem's real models carry callbacks, default scopes and
196
+ # a possibly separate connection. A migration must see the table it is
197
+ # migrating, exactly as it is on disk right now.
198
+ class ErrorLogRow < ActiveRecord::Base
199
+ self.table_name = "rails_error_dashboard_error_logs"
200
+ self.inheritance_column = nil
201
+ end
202
+
203
+ class OccurrenceRow < ActiveRecord::Base
204
+ self.table_name = "rails_error_dashboard_error_occurrences"
205
+ self.inheritance_column = nil
206
+ end
207
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Record WHICH repository (or Linear team) a linked issue belongs to.
4
+ #
5
+ # A linked issue was identified by provider + issue number alone, so issue 42
6
+ # on github.com/acme/api and issue 42 on github.com/acme/web were the same
7
+ # identity to RED. A validly signed webhook from the second repository
8
+ # resolved the error linked to the first. It matters for shared databases,
9
+ # several linked repositories, a repository rename, and Linear, where issue
10
+ # numbers are scoped per team and collide constantly.
11
+ #
12
+ # The repository identity is present in every payload RED already parses and
13
+ # in every issue URL LinkExistingIssue already matches -- it was extracted and
14
+ # then discarded. This column keeps it.
15
+ #
16
+ # Shape per provider, matching what each API client already expects as `repo`:
17
+ # github "owner/repo"
18
+ # gitlab "group/project" (URL-encoded by the client)
19
+ # codeberg "owner/repo" (Gitea/Forgejo)
20
+ # linear "ENG" (team key -- Linear has no repository)
21
+ #
22
+ # Nullable: rows linked before this migration have no recorded repository.
23
+ # Those are matched leniently (provider + number, as before) so an existing
24
+ # link keeps working, and the identity is filled in the first time a webhook
25
+ # or a re-link supplies it. A backfill is not possible in general -- the URL
26
+ # is the only evidence, and it is parsed here where it exists.
27
+ class AddIssueRepoIdentityToErrorLogs < ActiveRecord::Migration[7.0]
28
+ TABLE = :rails_error_dashboard_error_logs
29
+
30
+ def up
31
+ return unless table_exists?(TABLE)
32
+ return if column_exists?(TABLE, :external_issue_repo)
33
+
34
+ # 255: "group/subgroup/subgroup/project" on GitLab can be long, and this
35
+ # column is matched, not indexed on its own.
36
+ add_column TABLE, :external_issue_repo, :string, limit: 255
37
+
38
+ # The lookup a webhook performs: provider + number + repository.
39
+ add_index TABLE, [ :external_issue_provider, :external_issue_number, :external_issue_repo ],
40
+ name: "index_error_logs_on_issue_identity"
41
+
42
+ backfill_from_urls!
43
+ end
44
+
45
+ def down
46
+ return unless table_exists?(TABLE)
47
+
48
+ if index_name_exists?(TABLE, "index_error_logs_on_issue_identity")
49
+ remove_index TABLE, name: "index_error_logs_on_issue_identity"
50
+ end
51
+ remove_column TABLE, :external_issue_repo if column_exists?(TABLE, :external_issue_repo)
52
+ end
53
+
54
+ private
55
+
56
+ # Recover the repository from the stored issue URL where its shape makes
57
+ # that unambiguous. Anything unrecognised stays NULL and is matched
58
+ # leniently until a webhook or a re-link supplies the identity.
59
+ def backfill_from_urls!
60
+ ErrorLogRow.where.not(external_issue_url: nil)
61
+ .where(external_issue_repo: nil)
62
+ .find_each do |row|
63
+ repo = parse_repo(row.external_issue_url)
64
+ next if repo.blank?
65
+
66
+ ErrorLogRow.where(id: row.id).update_all(external_issue_repo: repo)
67
+ end
68
+ rescue => e
69
+ # A backfill must never block the migration: NULL simply means "matched
70
+ # leniently", which is exactly the pre-migration behaviour.
71
+ say "skipped issue repository backfill: #{e.class} - #{e.message}", true
72
+ end
73
+
74
+ def parse_repo(url)
75
+ case url.to_s
76
+ when %r{github\.com/([^/]+/[^/]+)/issues/\d+}i then Regexp.last_match(1)
77
+ when %r{gitlab\.com/([^/]+/[^/]+)/-/issues/\d+}i then Regexp.last_match(1)
78
+ when %r{codeberg\.org/([^/]+/[^/]+)/issues/\d+}i then Regexp.last_match(1)
79
+ when %r{linear\.app/[^/]+/issue/([A-Za-z][A-Za-z0-9]*)-\d+}i then Regexp.last_match(1).upcase
80
+ end
81
+ end
82
+
83
+ # Bare class: the real model carries callbacks, a default scope and possibly
84
+ # a separate connection. A migration reads the table as it is on disk.
85
+ class ErrorLogRow < ActiveRecord::Base
86
+ self.table_name = "rails_error_dashboard_error_logs"
87
+ self.inheritance_column = nil
88
+ end
89
+ end