rails_error_dashboard 0.11.4 → 0.11.6
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 +3 -2
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +31 -3
- data/app/jobs/rails_error_dashboard/baseline_calculation_job.rb +21 -0
- data/app/models/rails_error_dashboard/error_log.rb +4 -7
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/config/locales/fr.yml +30 -30
- data/db/migrate/20260908000001_add_release_to_error_occurrences.rb +45 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +20 -9
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +63 -28
- data/lib/rails_error_dashboard/commands/log_error.rb +11 -2
- data/lib/rails_error_dashboard/configuration.rb +7 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +39 -4
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +5 -16
- data/lib/rails_error_dashboard/queries/release_timeline.rb +72 -1
- data/lib/rails_error_dashboard/services/baseline_calculator.rb +75 -62
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -0
- data/lib/rails_error_dashboard/services/local_variable_capturer.rb +33 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +83 -22
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +52 -12
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +61 -2
- data/lib/rails_error_dashboard/value_objects/error_context.rb +19 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31b48d634cb3ef3737bfc4744993cd315deec8555bc6a0a7da55d6a3e3b011d5
|
|
4
|
+
data.tar.gz: 32fbeecb52dcbb0b48bf3f10f9fcc6da854ac20e0f1236a8fe0d37d1a3288475
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76346c5b3c5b5ba64e272d27024dc29a4635eca2024458a10b1eff9b0a842dd5cb96bbd671566d59ce3b53f44d5ca7d61250f4f620f6b4e0e4212f09d2c884d4
|
|
7
|
+
data.tar.gz: e9fcc3afb33b3d550c830453e1d8e1325081e6f182103975855dc337c56ea1735fbfccc3879b8af35cb1fdb119eb925ccff5b516108a2eb9d3eb300ff5094e8f
|
data/README.md
CHANGED
|
@@ -184,7 +184,7 @@ config.enable_breadcrumbs = true
|
|
|
184
184
|
|
|
185
185
|
Know your app's runtime state at the moment of failure — GC stats, process memory, thread count, connection pool utilization, Puma thread stats, RubyVM cache health, YJIT compilation stats, and deep runtime insights captured automatically.
|
|
186
186
|
|
|
187
|
-
- Sub-millisecond
|
|
187
|
+
- Sub-millisecond for the in-process metrics, every metric individually rescue-wrapped. The one exception is job-queue depth (five `COUNT`s for Solid Queue, a Redis round-trip for Sidekiq): those are queries against your queue store, cached for 10 s per process and switchable off with `config.system_health_queue_stats = false`
|
|
188
188
|
- No ObjectSpace scanning, no Thread backtraces, no subprocess calls
|
|
189
189
|
- RubyVM.stat: constant cache invalidations, shape cache stats
|
|
190
190
|
- YJIT runtime stats: compiled iseqs, invalidation count, code region sizes
|
|
@@ -476,9 +476,10 @@ Seven analysis engines built in:
|
|
|
476
476
|
<details>
|
|
477
477
|
<summary><strong>Local Variable + Instance Variable Capture</strong></summary>
|
|
478
478
|
|
|
479
|
-
See the
|
|
479
|
+
See the values of local variables and instance variables at the moment an exception was raised — the most valuable debugging context possible.
|
|
480
480
|
|
|
481
481
|
- TracePoint(`:raise`) captures locals and ivars before the stack unwinds
|
|
482
|
+
- Strings, arrays and hashes are snapshotted at raise time (one level deep, bounded by the limits below), so an `ensure` block that cleans up state does not overwrite what you see. Other objects are kept by reference and show their state at serialization time
|
|
482
483
|
- Configurable limits: max variable count, nesting depth, string truncation length
|
|
483
484
|
- Sensitive data auto-filtered via Rails `filter_parameters` — passwords, tokens, and PII never stored
|
|
484
485
|
- Never stores Binding objects — values extracted immediately, Binding is GC'd
|
|
@@ -37,8 +37,16 @@ module RailsErrorDashboard
|
|
|
37
37
|
# Reconstruct exception from serialized data
|
|
38
38
|
# @param data [Hash] Serialized exception data
|
|
39
39
|
# @return [Exception] Reconstructed exception object
|
|
40
|
+
#
|
|
41
|
+
# Never relies on the subclass constructor: many real exception classes
|
|
42
|
+
# take something other than a message (ActiveRecord::RecordInvalid wants
|
|
43
|
+
# the record, custom errors take keywords), and `Klass.new(message)` on
|
|
44
|
+
# those raised inside the job, which rescued it and dropped the capture.
|
|
45
|
+
# Allocate the right class so error_type/fingerprint stay correct, then
|
|
46
|
+
# set the message with Exception's own initializer, bypassing whatever
|
|
47
|
+
# the subclass expects. Fall back to the constructor for classes whose
|
|
48
|
+
# `message` needs state their initializer sets.
|
|
40
49
|
def reconstruct_exception(data)
|
|
41
|
-
# Get or create the exception class
|
|
42
50
|
exception_class = begin
|
|
43
51
|
data[:class_name].constantize
|
|
44
52
|
rescue NameError
|
|
@@ -46,13 +54,33 @@ module RailsErrorDashboard
|
|
|
46
54
|
StandardError
|
|
47
55
|
end
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
exception = allocate_exception(exception_class, data[:message]) ||
|
|
58
|
+
construct_exception(exception_class, data[:message]) ||
|
|
59
|
+
StandardError.new(data[:message])
|
|
51
60
|
|
|
52
61
|
# Restore the backtrace
|
|
53
62
|
exception.set_backtrace(data[:backtrace]) if data[:backtrace]
|
|
54
63
|
|
|
55
64
|
exception
|
|
56
65
|
end
|
|
66
|
+
|
|
67
|
+
def allocate_exception(exception_class, message)
|
|
68
|
+
return nil unless exception_class < Exception
|
|
69
|
+
|
|
70
|
+
exception = exception_class.allocate
|
|
71
|
+
Exception.instance_method(:initialize).bind_call(exception, message)
|
|
72
|
+
exception.message # a subclass `message` that needs constructor state raises here
|
|
73
|
+
exception
|
|
74
|
+
rescue StandardError, NoMemoryError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def construct_exception(exception_class, message)
|
|
79
|
+
exception = exception_class.new(message)
|
|
80
|
+
exception.message
|
|
81
|
+
exception
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
57
85
|
end
|
|
58
86
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Recalculates every error-type/platform baseline from recent history.
|
|
5
|
+
#
|
|
6
|
+
# Nothing in the gem schedules this: run it from the host's scheduler
|
|
7
|
+
# (Solid Queue recurring tasks, sidekiq-cron, whenever, cron + runner)
|
|
8
|
+
# roughly daily. Without it there are no baselines and no baseline alerts.
|
|
9
|
+
class BaselineCalculationJob < ApplicationJob
|
|
10
|
+
queue_as :default
|
|
11
|
+
|
|
12
|
+
def perform
|
|
13
|
+
result = Services::BaselineCalculator.calculate_all_baselines
|
|
14
|
+
Rails.logger.info("[RailsErrorDashboard] Baselines recalculated: #{result[:calculated]}")
|
|
15
|
+
result
|
|
16
|
+
rescue => e
|
|
17
|
+
Rails.logger.error("[RailsErrorDashboard] BaselineCalculationJob failed: #{e.class}: #{e.message}")
|
|
18
|
+
raise
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -339,13 +339,10 @@ module RailsErrorDashboard
|
|
|
339
339
|
return { anomaly: false, message: "Feature disabled" } unless RailsErrorDashboard.configuration.enable_baseline_alerts
|
|
340
340
|
return { anomaly: false, message: "No baseline available" } unless defined?(Queries::BaselineStats)
|
|
341
341
|
|
|
342
|
-
#
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
).where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
347
|
-
|
|
348
|
-
Queries::BaselineStats.new(error_type, platform).check_anomaly(today_count, sensitivity: sensitivity)
|
|
342
|
+
# Current hour / day / week counted in the baselines' own units — a
|
|
343
|
+
# day's count against an hourly baseline flagged everything.
|
|
344
|
+
Queries::BaselineStats.new(error_type, platform)
|
|
345
|
+
.check_current_anomaly(sensitivity: sensitivity, application_id: application_id)
|
|
349
346
|
end
|
|
350
347
|
|
|
351
348
|
# Detect cyclical occurrence patterns (daily/weekly rhythms)
|
|
@@ -34,5 +34,39 @@ module RailsErrorDashboard
|
|
|
34
34
|
# Database connection will be configured by the engine initializer
|
|
35
35
|
# after the user's configuration is loaded
|
|
36
36
|
# See lib/rails_error_dashboard/engine.rb
|
|
37
|
+
|
|
38
|
+
# Rails' default for a string column when the adapter has one (MySQL).
|
|
39
|
+
DEFAULT_STRING_LIMIT = 255
|
|
40
|
+
|
|
41
|
+
# Truncate string-typed attributes to their column limits. MySQL in strict
|
|
42
|
+
# mode rejects an over-long value outright, the capture path rescues the
|
|
43
|
+
# failure, and the error is silently lost — over a 300-character app
|
|
44
|
+
# version. Columns without a declared limit are clamped to the Rails
|
|
45
|
+
# default so behaviour is the same on every adapter. Text columns are
|
|
46
|
+
# untouched. Identity (error_hash) is computed from the raw values before
|
|
47
|
+
# this runs, so clamping display metadata never changes grouping.
|
|
48
|
+
# @param attributes [Hash] attribute name => value
|
|
49
|
+
# @return [Hash] a copy with over-long strings truncated
|
|
50
|
+
def self.clamp_string_attributes(attributes)
|
|
51
|
+
limits = string_column_limits
|
|
52
|
+
attributes.each_with_object({}) do |(name, value), clamped|
|
|
53
|
+
limit = limits[name.to_s]
|
|
54
|
+
clamped[name] = if limit && value.is_a?(String) && value.length > limit
|
|
55
|
+
value[0, limit]
|
|
56
|
+
else
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue => e
|
|
61
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] clamp_string_attributes failed: #{e.message}")
|
|
62
|
+
attributes
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Hash] column name => character limit, string columns only (memoized per class)
|
|
66
|
+
def self.string_column_limits
|
|
67
|
+
@string_column_limits ||= columns_hash.each_with_object({}) do |(name, column), limits|
|
|
68
|
+
limits[name] = column.limit || DEFAULT_STRING_LIMIT if column.type == :string
|
|
69
|
+
end
|
|
70
|
+
end
|
|
37
71
|
end
|
|
38
72
|
end
|
data/config/locales/fr.yml
CHANGED
|
@@ -59,7 +59,7 @@ fr:
|
|
|
59
59
|
activestorage: ActiveStorage
|
|
60
60
|
llm: LLM
|
|
61
61
|
n_plus_one: Requêtes N+1
|
|
62
|
-
deprecations:
|
|
62
|
+
deprecations: Dépréciations
|
|
63
63
|
swallowed: Avalées
|
|
64
64
|
diagnostic_dumps: Diagnostics
|
|
65
65
|
storms: Tempêtes
|
|
@@ -265,7 +265,7 @@ fr:
|
|
|
265
265
|
analytics: Analyse
|
|
266
266
|
variables:
|
|
267
267
|
local_title: Variables locales
|
|
268
|
-
local_hint: Variables de la frame
|
|
268
|
+
local_hint: Variables de la stack frame où l'exception a été levée
|
|
269
269
|
instance_title: Variables d'instance
|
|
270
270
|
instance_hint_html: Variables d'instance de %{klass}, là où l'exception a
|
|
271
271
|
été levée
|
|
@@ -281,11 +281,11 @@ fr:
|
|
|
281
281
|
filtered: "[FILTRÉE]"
|
|
282
282
|
truncated: tronquée
|
|
283
283
|
deprecations:
|
|
284
|
-
title: Avertissements de
|
|
285
|
-
hint: Avertissements de
|
|
284
|
+
title: Avertissements de dépréciation
|
|
285
|
+
hint: Avertissements de dépréciation Rails détectés pendant cette requête
|
|
286
286
|
column_warning: Avertissement
|
|
287
287
|
column_source: Source
|
|
288
|
-
footer_tip: Corrigez les
|
|
288
|
+
footer_tip: Corrigez les dépréciations avant de mettre Rails à jour pour éviter
|
|
289
289
|
les changements incompatibles.
|
|
290
290
|
upgrade_guide: Guide de mise à jour de Rails
|
|
291
291
|
n_plus_one:
|
|
@@ -789,17 +789,17 @@ fr:
|
|
|
789
789
|
jours.
|
|
790
790
|
how_breadcrumbs_html: Les breadcrumbs doivent être activés (%{option})
|
|
791
791
|
deprecations_page:
|
|
792
|
-
title: Avertissements de
|
|
793
|
-
empty_title: Aucun avertissement de
|
|
794
|
-
empty_body: Aucun avertissement de
|
|
792
|
+
title: Avertissements de dépréciation
|
|
793
|
+
empty_title: Aucun avertissement de dépréciation trouvé
|
|
794
|
+
empty_body: Aucun avertissement de dépréciation n'a été détecté dans les breadcrumbs
|
|
795
795
|
d'erreur des %{days} derniers jours.
|
|
796
|
-
how_title: 'Comment les
|
|
797
|
-
how_captured: Les avertissements de
|
|
796
|
+
how_title: 'Comment les dépréciations sont capturées :'
|
|
797
|
+
how_captured: Les avertissements de dépréciation de Rails sont capturés automatiquement
|
|
798
798
|
comme breadcrumbs
|
|
799
|
-
how_appear: Les
|
|
800
|
-
une requête comportant des avertissements de
|
|
799
|
+
how_appear: Les dépréciations apparaissent ici lorsqu'une erreur survient pendant
|
|
800
|
+
une requête comportant des avertissements de dépréciation
|
|
801
801
|
unique_warnings: Avertissements uniques
|
|
802
|
-
all_title: Tous les avertissements de
|
|
802
|
+
all_title: Tous les avertissements de dépréciation
|
|
803
803
|
column_warning: Avertissement
|
|
804
804
|
column_source: Source
|
|
805
805
|
n_plus_one_page:
|
|
@@ -885,7 +885,7 @@ fr:
|
|
|
885
885
|
docs: Documentation de Rack Attack
|
|
886
886
|
diagnostic_dumps_page:
|
|
887
887
|
title: Dumps de diagnostic
|
|
888
|
-
capture:
|
|
888
|
+
capture: Prendre un dump
|
|
889
889
|
empty_title: Aucun dump de diagnostic pour l'instant
|
|
890
890
|
empty_body: Créez à la demande un instantané de l'état de votre système à
|
|
891
891
|
des fins de débogage.
|
|
@@ -1147,7 +1147,7 @@ fr:
|
|
|
1147
1147
|
via SolidQueue, Sidekiq ou cron
|
|
1148
1148
|
digest_frequency: Fréquence d'envoi des e-mails de résumé
|
|
1149
1149
|
digest_recipients: Adresses e-mail (à défaut, les destinataires des notifications)
|
|
1150
|
-
enable_similar_errors: Trouver les erreurs
|
|
1150
|
+
enable_similar_errors: Trouver les erreurs similaires
|
|
1151
1151
|
enable_co_occurring_errors: Erreurs survenant conjointement
|
|
1152
1152
|
enable_error_cascades: Chaînes d'erreurs parent → enfant
|
|
1153
1153
|
enable_error_correlation: Analyse par version, utilisateur et heure
|
|
@@ -1158,7 +1158,7 @@ fr:
|
|
|
1158
1158
|
baseline_alert_severities: Gravités donnant lieu à une alerte
|
|
1159
1159
|
baseline_alert_cooldown_minutes: Minutes entre deux alertes pour le même type
|
|
1160
1160
|
d'erreur
|
|
1161
|
-
enable_source_code_integration: Afficher le code dans
|
|
1161
|
+
enable_source_code_integration: Afficher le code dans la backtrace
|
|
1162
1162
|
source_code_context_lines: Lignes avant et après la ligne ciblée
|
|
1163
1163
|
enable_git_blame: Afficher les informations git blame
|
|
1164
1164
|
source_code_cache_ttl: Durée de mise en cache du code source
|
|
@@ -1760,7 +1760,7 @@ fr:
|
|
|
1760
1760
|
age_days: "%{days} jours"
|
|
1761
1761
|
view: Voir
|
|
1762
1762
|
releases:
|
|
1763
|
-
version_chart_title: Erreurs par version
|
|
1763
|
+
version_chart_title: Erreurs par version
|
|
1764
1764
|
comparison_title: Dernière par rapport à la précédente
|
|
1765
1765
|
latest_version: Dernière version
|
|
1766
1766
|
latest_errors:
|
|
@@ -1781,9 +1781,9 @@ fr:
|
|
|
1781
1781
|
other: "%{count} critiques"
|
|
1782
1782
|
many: "%{count} critiques"
|
|
1783
1783
|
change: Variation
|
|
1784
|
-
empty: Données de
|
|
1785
|
-
problematic_title:
|
|
1786
|
-
problematic_hint_html: 'Les
|
|
1784
|
+
empty: Données de version insuffisantes pour une comparaison.
|
|
1785
|
+
problematic_title: Versions problématiques détectées
|
|
1786
|
+
problematic_hint_html: 'Les versions suivantes présentent des taux d''erreur
|
|
1787
1787
|
nettement plus élevés (>%{multiplier}x la moyenne) :'
|
|
1788
1788
|
problematic_entry: "%{count} erreurs (%{critical} critiques, +%{deviation}
|
|
1789
1789
|
% par rapport à la moyenne)"
|
|
@@ -1909,10 +1909,10 @@ fr:
|
|
|
1909
1909
|
releases:
|
|
1910
1910
|
title: Releases
|
|
1911
1911
|
empty:
|
|
1912
|
-
title: Aucune donnée de
|
|
1912
|
+
title: Aucune donnée de version trouvée
|
|
1913
1913
|
message: Aucune donnée de version n'a été détectée dans les erreurs des
|
|
1914
1914
|
%{days} derniers jours.
|
|
1915
|
-
how_to_title: 'Comment activer le suivi des
|
|
1915
|
+
how_to_title: 'Comment activer le suivi des versions :'
|
|
1916
1916
|
step_config_html: Définissez %{option} dans votre initializer
|
|
1917
1917
|
step_env_html: Ou définissez la variable d'environnement %{env_var}
|
|
1918
1918
|
step_git_sha_html: Le SHA Git est détecté automatiquement depuis %{git_sha},
|
|
@@ -1920,12 +1920,12 @@ fr:
|
|
|
1920
1920
|
step_captured: La version et le SHA sont capturés automatiquement lorsque
|
|
1921
1921
|
des erreurs surviennent
|
|
1922
1922
|
summary:
|
|
1923
|
-
releases:
|
|
1924
|
-
current_release:
|
|
1925
|
-
avg_errors: Erreurs moy. par
|
|
1926
|
-
problematic:
|
|
1923
|
+
releases: Versions
|
|
1924
|
+
current_release: Version actuelle
|
|
1925
|
+
avg_errors: Erreurs moy. par version
|
|
1926
|
+
problematic: Versions problématiques
|
|
1927
1927
|
current_card:
|
|
1928
|
-
title: '
|
|
1928
|
+
title: 'Version actuelle :'
|
|
1929
1929
|
live_badge: En ligne
|
|
1930
1930
|
errors: Erreurs
|
|
1931
1931
|
error_types: Types d'erreurs
|
|
@@ -1934,7 +1934,7 @@ fr:
|
|
|
1934
1934
|
git_sha: SHA Git
|
|
1935
1935
|
vs_previous: Par rapport à la précédente
|
|
1936
1936
|
timeline:
|
|
1937
|
-
title: Chronologie des
|
|
1937
|
+
title: Chronologie des versions
|
|
1938
1938
|
column_version: Version
|
|
1939
1939
|
column_git_sha: SHA Git
|
|
1940
1940
|
column_time_range: Période
|
|
@@ -1954,7 +1954,7 @@ fr:
|
|
|
1954
1954
|
moyenne des erreurs, <span class="badge bg-warning">Jaune</span> 1 à 2x
|
|
1955
1955
|
la moyenne, <span class="badge bg-danger">Rouge</span> > 2x la moyenne.
|
|
1956
1956
|
Les « nouvelles » erreurs sont les types d''erreurs apparus pour la première
|
|
1957
|
-
fois dans cette
|
|
1957
|
+
fois dans cette version.'
|
|
1958
1958
|
user_impact:
|
|
1959
1959
|
title: Impact utilisateur
|
|
1960
1960
|
empty:
|
|
@@ -2087,7 +2087,7 @@ fr:
|
|
|
2087
2087
|
sections:
|
|
2088
2088
|
error: Erreur
|
|
2089
2089
|
request: Requête
|
|
2090
|
-
deprecations:
|
|
2090
|
+
deprecations: Dépréciations
|
|
2091
2091
|
n_plus_one: N+1
|
|
2092
2092
|
cache: Cache
|
|
2093
2093
|
breadcrumbs: Breadcrumbs
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Release attribution belongs on the occurrence, not the group.
|
|
4
|
+
#
|
|
5
|
+
# An ErrorLog row is a GROUP: it keeps the app_version/git_sha of the first
|
|
6
|
+
# capture in its window and recurrences do not touch them. So an error first
|
|
7
|
+
# seen in v1 that kept firing after the v2 deploy was reported entirely under
|
|
8
|
+
# v1, and the release timeline could not answer "did this deploy make things
|
|
9
|
+
# worse". Each ErrorOccurrence now records the release it happened under, and
|
|
10
|
+
# ReleaseTimeline counts occurrences per release.
|
|
11
|
+
#
|
|
12
|
+
# Existing occurrences are backfilled from their group's release. That is the
|
|
13
|
+
# same attribution the dashboard showed before this migration (no worse), and
|
|
14
|
+
# it keeps historical releases populated instead of dropping to zero.
|
|
15
|
+
class AddReleaseToErrorOccurrences < ActiveRecord::Migration[7.0]
|
|
16
|
+
def up
|
|
17
|
+
return if column_exists?(:rails_error_dashboard_error_occurrences, :app_version)
|
|
18
|
+
|
|
19
|
+
add_column :rails_error_dashboard_error_occurrences, :app_version, :string
|
|
20
|
+
add_column :rails_error_dashboard_error_occurrences, :git_sha, :string
|
|
21
|
+
add_index :rails_error_dashboard_error_occurrences, [ :app_version, :occurred_at ],
|
|
22
|
+
name: "index_error_occurrences_on_version_and_time"
|
|
23
|
+
|
|
24
|
+
# Correlated subqueries rather than UPDATE ... FROM / JOIN: the one form
|
|
25
|
+
# SQLite, PostgreSQL and MySQL all accept.
|
|
26
|
+
execute <<~SQL.squish
|
|
27
|
+
UPDATE rails_error_dashboard_error_occurrences
|
|
28
|
+
SET app_version = (
|
|
29
|
+
SELECT app_version FROM rails_error_dashboard_error_logs
|
|
30
|
+
WHERE rails_error_dashboard_error_logs.id = rails_error_dashboard_error_occurrences.error_log_id
|
|
31
|
+
),
|
|
32
|
+
git_sha = (
|
|
33
|
+
SELECT git_sha FROM rails_error_dashboard_error_logs
|
|
34
|
+
WHERE rails_error_dashboard_error_logs.id = rails_error_dashboard_error_occurrences.error_log_id
|
|
35
|
+
)
|
|
36
|
+
WHERE app_version IS NULL
|
|
37
|
+
SQL
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def down
|
|
41
|
+
remove_index :rails_error_dashboard_error_occurrences, name: "index_error_occurrences_on_version_and_time"
|
|
42
|
+
remove_column :rails_error_dashboard_error_occurrences, :git_sha
|
|
43
|
+
remove_column :rails_error_dashboard_error_occurrences, :app_version
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -4,6 +4,10 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Commands
|
|
5
5
|
# Command: Find an existing error by hash or create a new one
|
|
6
6
|
# Uses pessimistic locking to prevent race conditions in multi-app scenarios.
|
|
7
|
+
# The whole find-and-write runs inside ONE transaction: a row lock taken
|
|
8
|
+
# by SELECT ... FOR UPDATE lives only as long as the transaction that took
|
|
9
|
+
# it, so a lock on a standalone SELECT was released before the UPDATE ran
|
|
10
|
+
# and two concurrent captures could both read count N and both write N+1.
|
|
7
11
|
#
|
|
8
12
|
# Search order:
|
|
9
13
|
# 1. Unresolved errors with same hash within 24 hours → increment occurrence count
|
|
@@ -37,16 +41,18 @@ module RailsErrorDashboard
|
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
def call
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
ErrorLog.transaction do
|
|
45
|
+
# Priority 1: Find unresolved match (existing behavior)
|
|
46
|
+
existing = find_unresolved
|
|
47
|
+
next increment_existing(existing) if existing
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
# Priority 2: Find resolved/wont_fix match → reopen
|
|
50
|
+
resolved = find_resolved
|
|
51
|
+
next reopen_existing(resolved) if resolved
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
# Priority 3: Create new record
|
|
54
|
+
create_new_or_retry
|
|
55
|
+
end
|
|
50
56
|
end
|
|
51
57
|
|
|
52
58
|
private
|
|
@@ -132,7 +138,12 @@ module RailsErrorDashboard
|
|
|
132
138
|
end
|
|
133
139
|
|
|
134
140
|
def create_new_or_retry
|
|
135
|
-
|
|
141
|
+
# Savepoint: on PostgreSQL a unique-violation poisons the enclosing
|
|
142
|
+
# transaction, and the retry lookups below would fail with "current
|
|
143
|
+
# transaction is aborted" instead of finding the winner's row.
|
|
144
|
+
ErrorLog.transaction(requires_new: true) do
|
|
145
|
+
ErrorLog.create!(@attributes.reverse_merge(resolved: false))
|
|
146
|
+
end
|
|
136
147
|
rescue ActiveRecord::RecordNotUnique
|
|
137
148
|
# Race condition: another process created the same error
|
|
138
149
|
retry_existing = with_environment(
|
|
@@ -8,7 +8,8 @@ module RailsErrorDashboard
|
|
|
8
8
|
# Runs in a background job (DB allowed). For each counted fingerprint:
|
|
9
9
|
# 1. Recompute the canonical error_hash from the stored identity parts
|
|
10
10
|
# (the gate's key deliberately omits application_id — resolved here)
|
|
11
|
-
# 2. Unresolved match →
|
|
11
|
+
# 2. Unresolved match → the ONE row FindOrIncrementError would pick
|
|
12
|
+
# (24 h window, exact environment first): occurrence_count += N
|
|
12
13
|
# 3. Resolved match → reopen (mirrors FindOrIncrementError semantics)
|
|
13
14
|
# 4. No match → create a minimal ErrorLog from the exemplar
|
|
14
15
|
#
|
|
@@ -59,26 +60,33 @@ module RailsErrorDashboard
|
|
|
59
60
|
|
|
60
61
|
error_hash = canonical_hash(entry, application)
|
|
61
62
|
last_seen = parse_time(entry["last_seen_at"]) || Time.current
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
63
|
+
# An explicit environment captured at the gate wins over the worker's
|
|
64
|
+
# own, mirroring LogError#resolve_environment for full captures.
|
|
65
|
+
env = current_environment && (entry["environment"].presence || current_environment)
|
|
66
|
+
|
|
67
|
+
# Priority 1: unresolved match — ONE row, chosen exactly as
|
|
68
|
+
# FindOrIncrementError chooses it (same hash + application, occurred
|
|
69
|
+
# within 24 h, exact environment before a legacy NULL row, most
|
|
70
|
+
# recently seen first), then a single atomic UPDATE on that id.
|
|
71
|
+
#
|
|
72
|
+
# It must be one row: the normal path opens a fresh group once the
|
|
73
|
+
# previous one's occurred_at falls outside the 24 h window, so a
|
|
74
|
+
# long-running unresolved error legitimately owns several unresolved
|
|
75
|
+
# rows. An update_all across the whole hash would add N to every one
|
|
76
|
+
# of them — seven real events becoming twelve counted occurrences.
|
|
77
|
+
target = unresolved_target(error_hash, application, env)
|
|
78
|
+
if target
|
|
79
|
+
if env && target.environment.blank?
|
|
80
|
+
ErrorLog.where(id: target.id).update_all([
|
|
81
|
+
"occurrence_count = occurrence_count + ?, last_seen_at = ?, environment = ?",
|
|
82
|
+
count, last_seen, env
|
|
83
|
+
])
|
|
84
|
+
else
|
|
85
|
+
ErrorLog.where(id: target.id).update_all([
|
|
86
|
+
"occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen
|
|
87
|
+
])
|
|
88
|
+
end
|
|
89
|
+
return count
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
# Priority 2: resolved/wont_fix match — reopen, mirroring
|
|
@@ -107,12 +115,17 @@ module RailsErrorDashboard
|
|
|
107
115
|
|
|
108
116
|
# Priority 3: first seen during count-only mode — minimal ErrorLog
|
|
109
117
|
# from the exemplar (no backtrace/context was captured; the next
|
|
110
|
-
# occurrence after the storm fills in detail via the normal path)
|
|
118
|
+
# occurrence after the storm fills in detail via the normal path).
|
|
119
|
+
#
|
|
120
|
+
# The exemplar message is RAW — the gate stores what the exception
|
|
121
|
+
# said so the canonical hash (computed above, from the raw message,
|
|
122
|
+
# exactly as the full path does) still lands on the same row. It must
|
|
123
|
+
# therefore go through the same redaction as LogError before it is
|
|
124
|
+
# persisted; storm protection and sensitive filtering are both on by
|
|
125
|
+
# default, and an incident is exactly when a password in a message
|
|
126
|
+
# must not reach the database.
|
|
111
127
|
create_attrs = {
|
|
112
|
-
environment: env
|
|
113
|
-
}.compact
|
|
114
|
-
ErrorLog.create!(
|
|
115
|
-
**create_attrs,
|
|
128
|
+
environment: env,
|
|
116
129
|
application_id: application.id,
|
|
117
130
|
error_type: entry["error_class"],
|
|
118
131
|
message: entry["message"],
|
|
@@ -124,10 +137,32 @@ module RailsErrorDashboard
|
|
|
124
137
|
occurrence_count: count,
|
|
125
138
|
error_hash: error_hash,
|
|
126
139
|
resolved: false
|
|
127
|
-
|
|
140
|
+
}.compact
|
|
141
|
+
ErrorLog.create!(**ErrorLog.clamp_string_attributes(Services::SensitiveDataFilter.filter_attributes(create_attrs)))
|
|
128
142
|
count
|
|
129
143
|
end
|
|
130
144
|
|
|
145
|
+
|
|
146
|
+
# The unresolved row the full capture path would increment right now.
|
|
147
|
+
def unresolved_target(error_hash, application, env)
|
|
148
|
+
scope = ErrorLog.unresolved
|
|
149
|
+
.where(error_hash: error_hash, application_id: application.id)
|
|
150
|
+
.where("occurred_at >= ?", 24.hours.ago)
|
|
151
|
+
if env
|
|
152
|
+
scope = scope.where(environment: [ env, nil ])
|
|
153
|
+
.order(Arel.sql("CASE WHEN environment IS NULL THEN 1 ELSE 0 END"))
|
|
154
|
+
end
|
|
155
|
+
scope.order(last_seen_at: :desc).select(:id, :environment).first
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The redaction LogError applies to a message, for exemplars that reach
|
|
159
|
+
# the database by any other route (first-seen rows, the storm ledger).
|
|
160
|
+
def redact_message(message)
|
|
161
|
+
return message if message.blank?
|
|
162
|
+
|
|
163
|
+
Services::SensitiveDataFilter.filter_attributes({ message: message.to_s })[:message]
|
|
164
|
+
end
|
|
165
|
+
|
|
131
166
|
# Mirrors ErrorHashGenerator.call exactly: same fields, same order,
|
|
132
167
|
# same normalization — so counts land on the same ErrorLog the full
|
|
133
168
|
# capture path would have used.
|
|
@@ -196,7 +231,7 @@ module RailsErrorDashboard
|
|
|
196
231
|
existing = event.top_fingerprints_list
|
|
197
232
|
fresh = @entries.map { |e|
|
|
198
233
|
e = e.with_indifferent_access if e.respond_to?(:with_indifferent_access)
|
|
199
|
-
{ "class" => e["error_class"], "message" => e["message"].to_s[0, 120], "count" => e["count"].to_i }
|
|
234
|
+
{ "class" => e["error_class"], "message" => redact_message(e["message"]).to_s[0, 120], "count" => e["count"].to_i }
|
|
200
235
|
}
|
|
201
236
|
|
|
202
237
|
merged = (existing + fresh)
|
|
@@ -299,6 +299,9 @@ module RailsErrorDashboard
|
|
|
299
299
|
# Apply sensitive data filtering (on by default)
|
|
300
300
|
attributes = Services::SensitiveDataFilter.filter_attributes(attributes)
|
|
301
301
|
|
|
302
|
+
# Fit string metadata to its columns (after hashing, before writing)
|
|
303
|
+
attributes = ErrorLog.clamp_string_attributes(attributes)
|
|
304
|
+
|
|
302
305
|
# Harvest breadcrumbs (if enabled and column exists)
|
|
303
306
|
if !storm_lite && ErrorLog.column_names.include?("breadcrumbs") && RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
304
307
|
# Sync path: harvest from current thread
|
|
@@ -377,13 +380,19 @@ module RailsErrorDashboard
|
|
|
377
380
|
# Track individual error occurrence for co-occurrence analysis (if table exists)
|
|
378
381
|
if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
379
382
|
begin
|
|
380
|
-
|
|
383
|
+
occurrence_attrs = {
|
|
381
384
|
error_log: error_log,
|
|
382
385
|
occurred_at: attributes[:occurred_at],
|
|
383
386
|
user_id: attributes[:user_id],
|
|
384
387
|
request_id: error_context.request_id,
|
|
385
388
|
session_id: error_context.session_id
|
|
386
|
-
|
|
389
|
+
}
|
|
390
|
+
# The release THIS event happened under. The group keeps its first
|
|
391
|
+
# release; per-release counts come from here (ReleaseTimeline).
|
|
392
|
+
occurrence_columns = ErrorOccurrence.column_names
|
|
393
|
+
occurrence_attrs[:app_version] = attributes[:app_version] if occurrence_columns.include?("app_version")
|
|
394
|
+
occurrence_attrs[:git_sha] = attributes[:git_sha] if occurrence_columns.include?("git_sha")
|
|
395
|
+
ErrorOccurrence.create(ErrorOccurrence.clamp_string_attributes(occurrence_attrs))
|
|
387
396
|
rescue => e
|
|
388
397
|
RailsErrorDashboard::Logger.error("Failed to create error occurrence: #{e.message}")
|
|
389
398
|
end
|
|
@@ -169,6 +169,8 @@ module RailsErrorDashboard
|
|
|
169
169
|
|
|
170
170
|
# System health snapshot (GC, memory, threads, connection pool at error time)
|
|
171
171
|
attr_accessor :enable_system_health # Master switch (default: false)
|
|
172
|
+
attr_accessor :system_health_queue_stats # Include job-queue depth counts (default: true)
|
|
173
|
+
attr_accessor :system_health_queue_stats_cache_seconds # Reuse queue counts this long per process (default: 10)
|
|
172
174
|
|
|
173
175
|
# Local variable capture via TracePoint(:raise)
|
|
174
176
|
attr_accessor :enable_local_variables # Master switch (default: false)
|
|
@@ -393,6 +395,11 @@ module RailsErrorDashboard
|
|
|
393
395
|
|
|
394
396
|
# System health snapshot defaults - OFF by default (opt-in)
|
|
395
397
|
@enable_system_health = false # Capture GC, memory, threads, connection pool at error time
|
|
398
|
+
# Queue-depth counts are queries against the queue store (five COUNTs
|
|
399
|
+
# for Solid Queue), the one part of the snapshot that is not sub-ms.
|
|
400
|
+
# Cached per process so an error burst runs them once per interval.
|
|
401
|
+
@system_health_queue_stats = true
|
|
402
|
+
@system_health_queue_stats_cache_seconds = 10
|
|
396
403
|
|
|
397
404
|
# Local variable capture defaults - OFF by default (opt-in)
|
|
398
405
|
@enable_local_variables = false # TracePoint(:raise) for local var capture
|
|
@@ -126,7 +126,10 @@ module RailsErrorDashboard
|
|
|
126
126
|
total = error_statistics[:total]
|
|
127
127
|
return 0 if total.zero?
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
# Same scoped relation as the denominator: an unscoped numerator
|
|
130
|
+
# counted every application's resolved errors against one
|
|
131
|
+
# application's total, and the "rate" went past 100%.
|
|
132
|
+
resolved_count = base_query.resolved.count
|
|
130
133
|
((resolved_count.to_f / total) * 100).round(1)
|
|
131
134
|
end
|
|
132
135
|
|