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.
- checksums.yaml +4 -4
- data/README.md +4 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
- data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
- data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
- data/app/models/rails_error_dashboard/error_log.rb +47 -0
- data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
- data/config/locales/de.yml +9 -0
- data/config/locales/en.yml +35 -0
- data/config/locales/es.yml +9 -0
- data/config/locales/fr.yml +10 -1
- data/config/locales/it.yml +9 -0
- data/config/locales/ja.yml +9 -0
- data/config/locales/pl.yml +9 -0
- data/config/locales/pt-BR.yml +9 -0
- data/config/locales/ru.yml +9 -0
- data/config/locales/uk.yml +9 -0
- data/config/locales/zh-CN.yml +9 -0
- data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
- data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
- data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
- data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
- data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
- data/lib/rails_error_dashboard/configuration.rb +4 -3
- data/lib/rails_error_dashboard/engine.rb +28 -0
- data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
- data/lib/rails_error_dashboard/version.rb +1 -1
- 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.
|
|
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 %>
|
data/config/locales/de.yml
CHANGED
|
@@ -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
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/es.yml
CHANGED
|
@@ -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
|
data/config/locales/fr.yml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RED dashboard translations — fr
|
|
2
2
|
#
|
|
3
|
-
#
|
|
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
|
data/config/locales/it.yml
CHANGED
|
@@ -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
|
data/config/locales/ja.yml
CHANGED
|
@@ -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: 解決率
|
data/config/locales/pl.yml
CHANGED
|
@@ -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ń
|
data/config/locales/pt-BR.yml
CHANGED
|
@@ -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
|
data/config/locales/ru.yml
CHANGED
|
@@ -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: Доля решённых
|
data/config/locales/uk.yml
CHANGED
|
@@ -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: Частка вирішених
|
data/config/locales/zh-CN.yml
CHANGED
|
@@ -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
|