rails_error_dashboard 0.13.0 → 0.14.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/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
- data/app/models/rails_error_dashboard/error_log.rb +10 -0
- data/app/models/rails_error_dashboard/event_count.rb +132 -0
- data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
- data/config/locales/de.yml +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/config/locales/fr.yml +2 -0
- data/config/locales/it.yml +2 -0
- data/config/locales/ja.yml +2 -0
- data/config/locales/pl.yml +2 -0
- data/config/locales/pt-BR.yml +2 -0
- data/config/locales/ru.yml +2 -0
- data/config/locales/uk.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/db/migrate/20260919000001_create_event_counts.rb +71 -0
- data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
- data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
- data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
- data/lib/rails_error_dashboard/configuration.rb +20 -0
- data/lib/rails_error_dashboard/engine.rb +13 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
- data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
- data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
- data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 15c3e5f83e8ce2990ce4649cd7c71be0542ca7149863a6bc85ff8505d5ee0a4d
|
|
4
|
+
data.tar.gz: ccd0115a385bfbfeab743f6e84a4ff77875f449a0adfc16c54b2fa0e21f43029
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 972ecd630a40e2aa58dc33e22492acafb4910310a078a6172458796b6073420a27f773e6709004bd6172eabc6db1acd0de9128f345fa3931a905ea59592e591b
|
|
7
|
+
data.tar.gz: a1b25a254a0154f94f260054ddcf7cedaf08039f4f62b234270eb9b7c0ffa84d03d0e09fb0213f7c53aa143200aa6edcfcd4916d54eb8495b6dcb375b8dca2c7
|
|
@@ -23,6 +23,16 @@ module RailsErrorDashboard
|
|
|
23
23
|
context[:_serialized_cause_chain] = exception_data[:cause_chain]
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# The type as REPORTED, independent of whether a Ruby class of that name
|
|
27
|
+
# exists here. reconstruct_exception falls back to StandardError for an
|
|
28
|
+
# unconstantizable name -- which is the normal case for a frontend or
|
|
29
|
+
# mobile report -- and reading error_type off the reconstructed object
|
|
30
|
+
# then renamed every such error StandardError, collapsing distinct
|
|
31
|
+
# client errors into one group.
|
|
32
|
+
if exception_data[:class_name].present?
|
|
33
|
+
context[:_reported_error_type] = exception_data[:class_name]
|
|
34
|
+
end
|
|
35
|
+
|
|
26
36
|
# Log the error synchronously in the background job.
|
|
27
37
|
# .new(...).call bypasses the async check (we're already async);
|
|
28
38
|
# worker: true makes an unreachable error store raise instead of being
|
|
@@ -47,6 +47,7 @@ module RailsErrorDashboard
|
|
|
47
47
|
cleanup_storm_flush_batches(cutoff)
|
|
48
48
|
cleanup_diagnostic_dumps(cutoff)
|
|
49
49
|
cleanup_swallowed_exceptions(cutoff)
|
|
50
|
+
cleanup_event_timing_gaps(cutoff)
|
|
50
51
|
|
|
51
52
|
expired_scope = self.class.expired_scope(cutoff)
|
|
52
53
|
return 0 if expired_scope.none?
|
|
@@ -58,6 +59,12 @@ module RailsErrorDashboard
|
|
|
58
59
|
|
|
59
60
|
# Batch delete dependent records (occurrences, comments, cascade patterns)
|
|
60
61
|
ErrorOccurrence.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
|
|
62
|
+
# Hour buckets for storm-shed events. Listed explicitly because this job
|
|
63
|
+
# deletes with delete_all, which does not fire the has_many :dependent
|
|
64
|
+
# callback on ErrorLog -- without this line the buckets outlive the group
|
|
65
|
+
# they describe, forever. The migration's own comment promised this
|
|
66
|
+
# pruning before the code existed.
|
|
67
|
+
EventCount.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all if EventCount.table_exists?
|
|
61
68
|
ErrorComment.where(error_log_id: expired_ids_scope).in_batches(of: 1000).delete_all
|
|
62
69
|
CascadePattern.where(parent_error_id: expired_ids_scope)
|
|
63
70
|
.or(CascadePattern.where(child_error_id: expired_ids_scope))
|
|
@@ -153,6 +160,53 @@ module RailsErrorDashboard
|
|
|
153
160
|
)
|
|
154
161
|
end
|
|
155
162
|
|
|
163
|
+
# Timing gaps are pruned by their OWN age, not by a group.
|
|
164
|
+
#
|
|
165
|
+
# A gap describes an interval, not an error, so there is no error_log_id to
|
|
166
|
+
# cascade from -- without this it would accumulate for the life of the
|
|
167
|
+
# installation. It also runs ABOVE the early return in #perform, which
|
|
168
|
+
# fires whenever no error logs happen to be expired: gaps expire
|
|
169
|
+
# independently of errors, exactly like the rack-attack events whose
|
|
170
|
+
# comment already warns about this.
|
|
171
|
+
#
|
|
172
|
+
# Safe to prune on covered_until: once the cutoff has moved past a gap, no
|
|
173
|
+
# window the dashboard displays can still reach it, so the warning it
|
|
174
|
+
# carries is no longer meaningful. Own rescue, like its siblings.
|
|
175
|
+
def cleanup_event_timing_gaps(cutoff)
|
|
176
|
+
return unless EventTimingGap.table_exists?
|
|
177
|
+
|
|
178
|
+
# Kept for the LONGER of the two horizons that govern it.
|
|
179
|
+
#
|
|
180
|
+
# This used the configured retention cutoff alone, which is wrong
|
|
181
|
+
# whenever retention is shorter than the window the dashboard reports on:
|
|
182
|
+
# at retention_days = 7 the gap was deleted while the events it qualified
|
|
183
|
+
# were still on the page -- ten monthly events, no warning, group still
|
|
184
|
+
# active. A gap outlives its own retention precisely because the figures
|
|
185
|
+
# it qualifies do.
|
|
186
|
+
#
|
|
187
|
+
# Deleting it only once BOTH clocks have passed means the warning can
|
|
188
|
+
# never disappear while the numbers it describes are still displayed.
|
|
189
|
+
# The reverse case is unaffected: with the 90-day default the retention
|
|
190
|
+
# cutoff is already the later of the two, so nothing is kept longer than
|
|
191
|
+
# before.
|
|
192
|
+
gap_cutoff = [ cutoff, Queries::DashboardStats::WIDEST_DISPLAYED_WINDOW.ago ].min
|
|
193
|
+
|
|
194
|
+
deleted = 0
|
|
195
|
+
EventTimingGap.where("covered_until < ?", gap_cutoff).in_batches(of: 1000) do |batch|
|
|
196
|
+
deleted += batch.delete_all
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if deleted > 0
|
|
200
|
+
RailsErrorDashboard::Logger.info(
|
|
201
|
+
"[RailsErrorDashboard] Retention cleanup: deleted #{deleted} event timing gaps"
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
rescue => e
|
|
205
|
+
RailsErrorDashboard::Logger.debug(
|
|
206
|
+
"[RailsErrorDashboard] Event timing gap retention cleanup failed: #{e.class} - #{e.message}"
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
156
210
|
# Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
|
|
157
211
|
# failure here (e.g. table not yet migrated) never blocks error cleanup.
|
|
158
212
|
def cleanup_rack_attack_events(cutoff)
|
|
@@ -21,11 +21,14 @@ module RailsErrorDashboard
|
|
|
21
21
|
entries: entries, overflow: overflow, episode: episode, batch_id: batch_id
|
|
22
22
|
)
|
|
23
23
|
|
|
24
|
-
# A batch that
|
|
25
|
-
#
|
|
26
|
-
#
|
|
24
|
+
# A batch that wrote nothing is not a delivered batch. Fail the job so
|
|
25
|
+
# Active Job retries it rather than dropping counts that were only ever
|
|
26
|
+
# held in one process's memory. Two shapes reach here: every entry failed
|
|
27
|
+
# permanently, and a transient store failure that rolled the whole batch
|
|
28
|
+
# back (retryable: true) -- the latter is intact and safe to replay.
|
|
27
29
|
if result.is_a?(Hash) && result[:success] == false
|
|
28
|
-
|
|
30
|
+
reason = result[:retryable] ? "storm flush rolled back" : "storm flush reconciled nothing"
|
|
31
|
+
raise FlushFailed, "#{reason}: #{result[:error]}"
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
result
|
|
@@ -37,6 +37,16 @@ module RailsErrorDashboard
|
|
|
37
37
|
# Association for tracking individual error occurrences
|
|
38
38
|
has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
|
|
39
39
|
|
|
40
|
+
# Hour buckets for storm-shed events. delete_all, not destroy: these rows
|
|
41
|
+
# are pure counters with no callbacks, and a group can own one per hour.
|
|
42
|
+
#
|
|
43
|
+
# The association has to live HERE. `dependent:` on EventCount's own
|
|
44
|
+
# belongs_to is rejected by Rails (":dependent option must be one of
|
|
45
|
+
# [:destroy, :delete, :destroy_async]"), so the cleanup can only be
|
|
46
|
+
# declared from the parent side. Retention deletes them separately as well,
|
|
47
|
+
# because it uses delete_all, which does not fire callbacks.
|
|
48
|
+
has_many :event_counts, class_name: "RailsErrorDashboard::EventCount", dependent: :delete_all
|
|
49
|
+
|
|
40
50
|
# Comments used as internal audit trail for workflow actions (snooze, mute, status changes).
|
|
41
51
|
# Manual comment form removed in v0.6 — discussion now lives on issue tracker.
|
|
42
52
|
has_many :comments, class_name: "RailsErrorDashboard::ErrorComment", foreign_key: :error_log_id, dependent: :destroy
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# How many storm-shed events landed on one error group in one hour.
|
|
5
|
+
#
|
|
6
|
+
# Storm protection sheds events by folding N of them into the group's
|
|
7
|
+
# occurrence_count and writing no ErrorOccurrence row. That keeps the total
|
|
8
|
+
# exact but leaves the events with no timestamp of their own, so a window
|
|
9
|
+
# query has nothing to filter them by. This table gives them one.
|
|
10
|
+
#
|
|
11
|
+
# Window volume is therefore:
|
|
12
|
+
#
|
|
13
|
+
# ErrorOccurrence rows in the window + EventCount buckets in the window
|
|
14
|
+
#
|
|
15
|
+
# Ordinary captures contribute the first term, shed events the second, and
|
|
16
|
+
# neither is double counted: an event that wrote an occurrence row is never
|
|
17
|
+
# also bucketed here.
|
|
18
|
+
#
|
|
19
|
+
# Inherits ErrorLogsRecord so separate-database routing applies.
|
|
20
|
+
class EventCount < ErrorLogsRecord
|
|
21
|
+
self.table_name = "rails_error_dashboard_event_counts"
|
|
22
|
+
|
|
23
|
+
belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog", optional: true
|
|
24
|
+
|
|
25
|
+
scope :in_window, ->(from, to = nil) {
|
|
26
|
+
scope = where(arel_table[:bucket_at].gteq(from))
|
|
27
|
+
to ? scope.where(arel_table[:bucket_at].lt(to)) : scope
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Bucket width, shared with the PRODUCER.
|
|
31
|
+
#
|
|
32
|
+
# This must equal CountBuffer::BUCKET_SECONDS, and there is a spec that
|
|
33
|
+
# asserts it. The producer tallies 15-minute buckets precisely so that a
|
|
34
|
+
# local midnight falls on a bucket EDGE in every UTC offset in use --
|
|
35
|
+
# including +05:30 (Kolkata) and +05:45 (Kathmandu). Rounding those buckets
|
|
36
|
+
# to the hour here destroyed exactly the property the producer paid for:
|
|
37
|
+
# a storm event at 23:59:30 and one at 00:00:30 in Kolkata shared one
|
|
38
|
+
# bucket, and a day's total was wrong by the whole straddle.
|
|
39
|
+
BUCKET_SECONDS = Services::StormProtection::CountBuffer::BUCKET_SECONDS
|
|
40
|
+
|
|
41
|
+
# The bucket a time belongs to, in UTC. One definition, used by the writer
|
|
42
|
+
# and the readers -- a mismatch here silently splits or merges a bucket.
|
|
43
|
+
# @param time [Time]
|
|
44
|
+
# @return [Time]
|
|
45
|
+
def self.bucket_for(time)
|
|
46
|
+
time = (time || Time.current)
|
|
47
|
+
Time.at((time.to_i / BUCKET_SECONDS) * BUCKET_SECONDS).utc
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Add +count+ shed events to (error_log_id, bucket_at), creating the row if
|
|
51
|
+
# it is not there yet.
|
|
52
|
+
#
|
|
53
|
+
# Adapter-portable by hand rather than via upsert_all: the increment has to
|
|
54
|
+
# read the existing value, and the ON CONFLICT / ON DUPLICATE KEY syntaxes
|
|
55
|
+
# differ. The UPDATE-first shape means the common case (a storm flushing
|
|
56
|
+
# repeatedly into the same hour) is a single statement, and the INSERT race
|
|
57
|
+
# is resolved by retrying the UPDATE once.
|
|
58
|
+
#
|
|
59
|
+
# @return [Symbol] :written when the bucket landed, :unavailable when the
|
|
60
|
+
# rollup is permanently unusable (no table, bad args, an adapter that
|
|
61
|
+
# refuses the statement). A TRANSIENT failure raises instead.
|
|
62
|
+
#
|
|
63
|
+
# Three states, not a boolean: the caller has to tell "the bucket is
|
|
64
|
+
# permanently unavailable, degrade" from "the write failed, retry the
|
|
65
|
+
# batch". Collapsing both into false made FlushStormCounts abort the
|
|
66
|
+
# whole transaction on a host that had simply never migrated the rollup
|
|
67
|
+
# table -- rolling back the authoritative lifetime count with it.
|
|
68
|
+
def self.accumulate(error_log_id:, bucket_at:, count:)
|
|
69
|
+
return :unavailable unless error_log_id && count.to_i.positive?
|
|
70
|
+
return :unavailable unless table_exists?
|
|
71
|
+
|
|
72
|
+
bucket = bucket_for(bucket_at)
|
|
73
|
+
updated = where(error_log_id: error_log_id, bucket_at: bucket)
|
|
74
|
+
.update_all([ "count = count + ?, updated_at = ?", count.to_i, Time.current ])
|
|
75
|
+
return :written if updated.positive?
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
# requires_new: a failed INSERT aborts its transaction on PostgreSQL,
|
|
79
|
+
# which would take the caller's surrounding transaction with it.
|
|
80
|
+
transaction(requires_new: true) do
|
|
81
|
+
create!(error_log_id: error_log_id, bucket_at: bucket, count: count.to_i)
|
|
82
|
+
end
|
|
83
|
+
:written
|
|
84
|
+
rescue ActiveRecord::RecordNotUnique
|
|
85
|
+
# Another process created the same bucket between the UPDATE and the
|
|
86
|
+
# INSERT. The row exists now, so the UPDATE that missed a moment ago
|
|
87
|
+
# succeeds.
|
|
88
|
+
where(error_log_id: error_log_id, bucket_at: bucket)
|
|
89
|
+
.update_all([ "count = count + ?, updated_at = ?", count.to_i, Time.current ])
|
|
90
|
+
.positive? ? :written : :unavailable
|
|
91
|
+
end
|
|
92
|
+
rescue *Commands::LogError::RETRYABLE_STORE_ERRORS => e
|
|
93
|
+
# TRANSIENT: the store may be back in a moment. Do NOT swallow it.
|
|
94
|
+
#
|
|
95
|
+
# Returning false here let FlushStormCounts finalize its batch ledger
|
|
96
|
+
# with the bucket missing, so the replay was suppressed as
|
|
97
|
+
# already-applied and those events were erased from every time window --
|
|
98
|
+
# permanently -- while the lifetime count stayed correct. The caller
|
|
99
|
+
# turns this into an abort-and-retry of the whole batch.
|
|
100
|
+
RailsErrorDashboard::Logger.debug(
|
|
101
|
+
"[RailsErrorDashboard] EventCount.accumulate hit a transient failure: #{e.class} - #{e.message}"
|
|
102
|
+
)
|
|
103
|
+
raise
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
# PERMANENT: a malformed row, a missing table, an adapter that refuses
|
|
106
|
+
# this statement. Retrying cannot help, and failing the flush would turn
|
|
107
|
+
# a lost time bucket into a lost COUNT -- the authoritative total is
|
|
108
|
+
# occurrence_count on the group, and it is already written.
|
|
109
|
+
#
|
|
110
|
+
# This is the only case the old comment actually described.
|
|
111
|
+
RailsErrorDashboard::Logger.debug(
|
|
112
|
+
"[RailsErrorDashboard] EventCount.accumulate failed permanently: #{e.class} - #{e.message}"
|
|
113
|
+
)
|
|
114
|
+
:unavailable
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether the rollup table is usable.
|
|
118
|
+
#
|
|
119
|
+
# A transient connection failure here is NOT "the table does not exist":
|
|
120
|
+
# swallowing it returned false before any write was attempted, so a storm
|
|
121
|
+
# flush reported success with no bucket written, and EventVolume silently
|
|
122
|
+
# dropped the bucket term from its reads. Let the transient class through
|
|
123
|
+
# so the caller can retry; only a genuinely absent table returns false.
|
|
124
|
+
def self.table_exists?
|
|
125
|
+
connection.table_exists?(table_name)
|
|
126
|
+
rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
|
|
127
|
+
raise
|
|
128
|
+
rescue StandardError
|
|
129
|
+
false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# An interval whose per-event timing evidence was lost.
|
|
5
|
+
#
|
|
6
|
+
# See the migration for why this is its own table rather than a flag on the
|
|
7
|
+
# storm episode: the episode is optional, and it is written after the counts
|
|
8
|
+
# transaction has already committed.
|
|
9
|
+
#
|
|
10
|
+
# Inherits ErrorLogsRecord so separate-database routing applies.
|
|
11
|
+
class EventTimingGap < ErrorLogsRecord
|
|
12
|
+
self.table_name = "rails_error_dashboard_event_timing_gaps"
|
|
13
|
+
|
|
14
|
+
# Gaps overlapping [from, to). Open-ended when +to+ is nil.
|
|
15
|
+
#
|
|
16
|
+
# Overlap, not containment: a gap that began before the window and runs
|
|
17
|
+
# into it still makes that window's timing unreliable. Checking only
|
|
18
|
+
# "starts inside the window" is the mistake the episode predicate made.
|
|
19
|
+
scope :overlapping, ->(from, to = nil) {
|
|
20
|
+
scope = where(arel_table[:covered_until].gteq(from))
|
|
21
|
+
to ? scope.where(arel_table[:covered_from].lt(to)) : scope
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Whether any recorded gap affects the given window.
|
|
25
|
+
#
|
|
26
|
+
# Every guard is deliberate: the table may not be migrated on an older
|
|
27
|
+
# host, and this is read on the dashboard path, which must never raise.
|
|
28
|
+
#
|
|
29
|
+
# @param from [Time] start of the window being displayed
|
|
30
|
+
# @param application_id [Integer, nil]
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def self.affecting?(from, application_id: nil)
|
|
33
|
+
return false unless table_exists?
|
|
34
|
+
|
|
35
|
+
scope = overlapping(from)
|
|
36
|
+
# A NULL application_id means the gap applies everywhere, so it is
|
|
37
|
+
# included whatever is being filtered for.
|
|
38
|
+
scope = scope.where(application_id: [ application_id, nil ]) if application_id.present?
|
|
39
|
+
scope.exists?
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
RailsErrorDashboard::Logger.debug(
|
|
42
|
+
"[RailsErrorDashboard] EventTimingGap.affecting? failed: #{e.class} - #{e.message}"
|
|
43
|
+
)
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.table_exists?
|
|
48
|
+
connection.table_exists?(table_name)
|
|
49
|
+
rescue *Commands::LogError::RETRYABLE_STORE_ERRORS
|
|
50
|
+
raise
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -744,9 +744,54 @@ dd { color: var(--text-primary); }
|
|
|
744
744
|
}
|
|
745
745
|
.red-stat-grid > * { min-width: 0; }
|
|
746
746
|
|
|
747
|
-
/* Error detail
|
|
747
|
+
/* Error detail hero
|
|
748
|
+
Same failure as the stat grids above: an inline `display: flex` cannot carry
|
|
749
|
+
a media query. The action row held `flex-shrink: 0` while the title column
|
|
750
|
+
was plain `flex: 1`, so at 390px up to five buttons kept their full width
|
|
751
|
+
and the title absorbed every pixel of the squeeze — "NoMethodError" wrapped
|
|
752
|
+
to roughly one character per line, making a very tall header before the
|
|
753
|
+
backtrace on the one screen an on-call engineer actually reads.
|
|
754
|
+
|
|
755
|
+
`flex: 1 1 260px` is the load-bearing part: the 260px basis stops the text
|
|
756
|
+
column collapsing toward zero. Dropping flex-shrink from the actions lets
|
|
757
|
+
them wrap onto their own line instead. */
|
|
758
|
+
.red-error-hero {
|
|
759
|
+
display: flex;
|
|
760
|
+
align-items: flex-start;
|
|
761
|
+
justify-content: space-between;
|
|
762
|
+
gap: var(--space-4);
|
|
763
|
+
flex-wrap: wrap;
|
|
764
|
+
}
|
|
765
|
+
.red-error-hero-text { flex: 1 1 260px; min-width: 0; }
|
|
766
|
+
.red-error-hero-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
767
|
+
@media (max-width: 575.98px) {
|
|
768
|
+
/* Full-width buttons once stacked: a wrapped row of five reads as rubble. */
|
|
769
|
+
.red-error-hero-actions { width: 100%; }
|
|
770
|
+
.red-error-hero-actions .btn { flex: 1 1 auto; }
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/* Error detail responsive grid
|
|
774
|
+
The single-column rule below stacks the panels, but `1fr` is min-content by
|
|
775
|
+
default, so a track still refused to shrink below its widest child: at 390px
|
|
776
|
+
the grid measured 334px while its content needed 419px, and the page scrolled
|
|
777
|
+
sideways independently of the hero. Same fix as the stat grids: minmax's 0
|
|
778
|
+
floor lets the track shrink, and min-width:0 lets its children do the same. */
|
|
748
779
|
.red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
|
|
749
|
-
|
|
780
|
+
.red-detail-grid > * { min-width: 0; }
|
|
781
|
+
@media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: minmax(0, 1fr); } }
|
|
782
|
+
|
|
783
|
+
/* Detail tables inside those panels (Request Context, Metadata).
|
|
784
|
+
Their label column is a fixed `width="200"` and their values are URLs and
|
|
785
|
+
JSON, so the table's intrinsic width stayed ~416px however narrow the panel
|
|
786
|
+
became — the last thing still pushing the detail page sideways on a phone
|
|
787
|
+
once the grid and the hero were fixed. Below the stacking breakpoint the
|
|
788
|
+
label column narrows and long values wrap on any character, so the table
|
|
789
|
+
fits its panel instead of widening the page. */
|
|
790
|
+
@media (max-width: 1023.98px) {
|
|
791
|
+
.red-detail-grid .card-body > .table { table-layout: fixed; width: 100%; }
|
|
792
|
+
.red-detail-grid .card-body > .table th { width: 40%; }
|
|
793
|
+
.red-detail-grid .card-body > .table td { overflow-wrap: anywhere; }
|
|
794
|
+
}
|
|
750
795
|
|
|
751
796
|
/* Env badge */
|
|
752
797
|
.red-env-badge {
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
<div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_lite") %></small></div>
|
|
31
31
|
<% when "minimal" %>
|
|
32
32
|
<div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_minimal") %></small></div>
|
|
33
|
+
<% when "partial" %>
|
|
34
|
+
<div><small class="text-muted"><%= red_t("red.errors.request_context.snapshot_fidelity_partial") %></small></div>
|
|
33
35
|
<% end %>
|
|
34
36
|
<% end %>
|
|
35
37
|
</td>
|
|
@@ -16,6 +16,18 @@
|
|
|
16
16
|
</div>
|
|
17
17
|
<% end %>
|
|
18
18
|
|
|
19
|
+
<%# A storm episode degraded without writing its time buckets, so the
|
|
20
|
+
time-window figures below rest on each group's first-seen timestamp
|
|
21
|
+
rather than on per-event records. The COUNTS are still exact -- only
|
|
22
|
+
their placement in time is approximate, and saying so beats presenting
|
|
23
|
+
a figure of unknown completeness as fact. %>
|
|
24
|
+
<% if @stats[:event_timing_incomplete] %>
|
|
25
|
+
<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);">
|
|
26
|
+
<i class="bi bi-clock-history"></i>
|
|
27
|
+
<strong><%= red_t("red.errors.overview_page.event_timing_incomplete") %></strong>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
19
31
|
<!-- Spike / Critical Alerts Banner -->
|
|
20
32
|
<% if @critical_alerts.any? %>
|
|
21
33
|
<%
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
</div>
|
|
14
14
|
|
|
15
15
|
<!-- Hero card -->
|
|
16
|
-
<div
|
|
17
|
-
<div
|
|
16
|
+
<div class="red-error-hero" style="padding: var(--space-6); background: var(--surface-primary); border-radius: var(--radius-md); border: 1px solid var(--border-primary); margin-bottom: var(--space-4);">
|
|
17
|
+
<div class="red-error-hero-text">
|
|
18
18
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
|
19
19
|
<span class="badge bg-<%= severity_color(@error.severity) %>" style="display: inline-flex; align-items: center; gap: 4px;">
|
|
20
20
|
<span style="width: 6px; height: 6px; border-radius: 50%; background: currentColor;"></span>
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
<span><%= red_t("red.errors.show.hero.last_seen_html", time: local_time_ago(@error.last_seen_at)) %></span>
|
|
76
76
|
</div>
|
|
77
77
|
</div>
|
|
78
|
-
<div
|
|
78
|
+
<div class="red-error-hero-actions">
|
|
79
79
|
<% unless @error.resolved? %>
|
|
80
80
|
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#resolveModal" style="font-weight: 600;">
|
|
81
81
|
<%= red_t("red.errors.show.actions.resolve") %>
|
data/config/locales/de.yml
CHANGED
|
@@ -583,6 +583,7 @@ de:
|
|
|
583
583
|
snapshot_stale: aus einem früheren Auftreten — spätere lieferten keinen Kontext
|
|
584
584
|
snapshot_fidelity_lite: reduzierte Erfassung (der Storm-Schutz hat die Kontext-Payloads verworfen)
|
|
585
585
|
snapshot_fidelity_minimal: während eines Storms gezählt — es wurde kein Kontext erfasst
|
|
586
|
+
snapshot_fidelity_partial: gemischter Snapshot — einige Felder stammen aus einem früheren Vorkommen
|
|
586
587
|
snapshot_unknown: erfasst, bevor RED die Herkunft von Momentaufnahmen aufzeichnete
|
|
587
588
|
similar_errors:
|
|
588
589
|
title: Ähnliche Fehler
|
|
@@ -940,6 +941,7 @@ de:
|
|
|
940
941
|
rate_per_hour: "%{value}/Std."
|
|
941
942
|
affected_users_incomplete: mindestens — der Storm-Schutz hat Detaildaten pro Ereignis verworfen
|
|
942
943
|
data_unavailable: Statistiken sind derzeit nicht verfügbar — die Zahlen unten sind keine Messung.
|
|
944
|
+
event_timing_incomplete: "Zeitangaben sind unvollständig — der Sturmschutz hat Zeitstempel verworfen; die Gesamtzahlen stimmen."
|
|
943
945
|
unresolved: Offen
|
|
944
946
|
unresolved_hint: Behebung ausstehend
|
|
945
947
|
resolution_rate: Behebungsquote
|
data/config/locales/en.yml
CHANGED
|
@@ -821,6 +821,7 @@ en:
|
|
|
821
821
|
# but no backtrace or context was ever captured for it.
|
|
822
822
|
snapshot_fidelity_lite: "reduced capture (storm protection shed the context payloads)"
|
|
823
823
|
snapshot_fidelity_minimal: "counted during a storm — no context was captured"
|
|
824
|
+
snapshot_fidelity_partial: "mixed snapshot — some fields are kept from an earlier occurrence"
|
|
824
825
|
snapshot_unknown: "captured before RED recorded snapshot provenance"
|
|
825
826
|
|
|
826
827
|
similar_errors:
|
|
@@ -1330,6 +1331,7 @@ en:
|
|
|
1330
1331
|
# Zero errors and "the dashboard could not read its data" are
|
|
1331
1332
|
# different states. This banner says which one you are looking at.
|
|
1332
1333
|
data_unavailable: "Statistics are currently unavailable — the figures below are not a measurement."
|
|
1334
|
+
event_timing_incomplete: "Event timing is incomplete — storm protection dropped per-event timestamps; the totals are still exact."
|
|
1333
1335
|
unresolved: "Unresolved"
|
|
1334
1336
|
unresolved_hint: "Pending resolution"
|
|
1335
1337
|
resolution_rate: "Resolution Rate"
|
data/config/locales/es.yml
CHANGED
|
@@ -608,6 +608,7 @@ es:
|
|
|
608
608
|
snapshot_stale: de una aparición anterior — las posteriores no aportaron contexto
|
|
609
609
|
snapshot_fidelity_lite: captura reducida (la protección ante storms descartó los payloads de contexto)
|
|
610
610
|
snapshot_fidelity_minimal: contabilizado durante un storm — no se capturó ningún contexto
|
|
611
|
+
snapshot_fidelity_partial: instantánea mixta — algunos campos provienen de una aparición anterior
|
|
611
612
|
snapshot_unknown: capturada antes de que RED registrara la procedencia de las instantáneas
|
|
612
613
|
similar_errors:
|
|
613
614
|
title: Errores similares
|
|
@@ -970,6 +971,7 @@ es:
|
|
|
970
971
|
rate_per_hour: "%{value}/h"
|
|
971
972
|
affected_users_incomplete: al menos — la protección ante storms descartó el detalle por evento
|
|
972
973
|
data_unavailable: Las estadísticas no están disponibles por ahora — las cifras siguientes no son una medición.
|
|
974
|
+
event_timing_incomplete: "Las marcas de tiempo están incompletas — la protección contra tormentas las descartó; los totales siguen siendo exactos."
|
|
973
975
|
unresolved: Sin resolver
|
|
974
976
|
unresolved_hint: Pendientes de resolución
|
|
975
977
|
resolution_rate: Tasa de resolución
|
data/config/locales/fr.yml
CHANGED
|
@@ -607,6 +607,7 @@ fr:
|
|
|
607
607
|
snapshot_stale: issu d'une occurrence antérieure — les suivantes n'ont apporté aucun contexte
|
|
608
608
|
snapshot_fidelity_lite: capture réduite (la protection contre les storms a écarté les payloads de contexte)
|
|
609
609
|
snapshot_fidelity_minimal: comptabilisé pendant un storm — aucun contexte n'a été capturé
|
|
610
|
+
snapshot_fidelity_partial: instantané mixte — certains champs proviennent d'une occurrence antérieure
|
|
610
611
|
snapshot_unknown: capturé avant que RED n'enregistre la provenance des instantanés
|
|
611
612
|
similar_errors:
|
|
612
613
|
title: Erreurs similaires
|
|
@@ -971,6 +972,7 @@ fr:
|
|
|
971
972
|
rate_per_hour: "%{value}/h"
|
|
972
973
|
affected_users_incomplete: au moins — la protection contre les storms a écarté le détail par événement
|
|
973
974
|
data_unavailable: Les statistiques sont indisponibles pour le moment — les chiffres ci-dessous ne sont pas une mesure.
|
|
975
|
+
event_timing_incomplete: "L'horodatage est incomplet — la protection anti-tempête a supprimé les horodatages ; les totaux restent exacts."
|
|
974
976
|
unresolved: Non résolues
|
|
975
977
|
unresolved_hint: En attente de résolution
|
|
976
978
|
resolution_rate: Taux de résolution
|
data/config/locales/it.yml
CHANGED
|
@@ -601,6 +601,7 @@ it:
|
|
|
601
601
|
snapshot_stale: da un'occorrenza precedente — quelle successive non hanno fornito contesto
|
|
602
602
|
snapshot_fidelity_lite: acquisizione ridotta (la protezione dagli storm ha scartato i payload di contesto)
|
|
603
603
|
snapshot_fidelity_minimal: conteggiato durante uno storm — non è stato acquisito alcun contesto
|
|
604
|
+
snapshot_fidelity_partial: snapshot misto — alcuni campi provengono da un'occorrenza precedente
|
|
604
605
|
snapshot_unknown: acquisita prima che RED registrasse la provenienza delle istantanee
|
|
605
606
|
similar_errors:
|
|
606
607
|
title: Errori simili
|
|
@@ -962,6 +963,7 @@ it:
|
|
|
962
963
|
rate_per_hour: "%{value}/h"
|
|
963
964
|
affected_users_incomplete: almeno — la protezione dagli storm ha scartato il dettaglio per evento
|
|
964
965
|
data_unavailable: Le statistiche non sono al momento disponibili — i valori seguenti non sono una misurazione.
|
|
966
|
+
event_timing_incomplete: "Le marche temporali sono incomplete — la protezione tempeste le ha scartate; i totali restano esatti."
|
|
965
967
|
unresolved: Non risolti
|
|
966
968
|
unresolved_hint: In attesa di risoluzione
|
|
967
969
|
resolution_rate: Tasso di risoluzione
|
data/config/locales/ja.yml
CHANGED
|
@@ -532,6 +532,7 @@ ja:
|
|
|
532
532
|
snapshot_stale: より前の発生から取得 — それ以降の発生はコンテキストを伴いませんでした
|
|
533
533
|
snapshot_fidelity_lite: 縮小された取得 (storm 保護によりコンテキストの payload が破棄されました)
|
|
534
534
|
snapshot_fidelity_minimal: storm 中にカウントされました — コンテキストは取得されていません
|
|
535
|
+
snapshot_fidelity_partial: 混在したスナップショット — 一部のフィールドは以前の発生時のものです
|
|
535
536
|
snapshot_unknown: RED がスナップショットの出所を記録する前に取得されました
|
|
536
537
|
similar_errors:
|
|
537
538
|
title: 類似エラー
|
|
@@ -846,6 +847,7 @@ ja:
|
|
|
846
847
|
rate_per_hour: "%{value}/時"
|
|
847
848
|
affected_users_incomplete: 最少値 — storm 保護によりイベントごとの詳細が破棄されました
|
|
848
849
|
data_unavailable: 統計は現在利用できません — 以下の数値は計測値ではありません。
|
|
850
|
+
event_timing_incomplete: "発生時刻の記録が不完全です — ストーム保護がタイムスタンプを破棄しました。合計値は正確です。"
|
|
849
851
|
unresolved: 未解決
|
|
850
852
|
unresolved_hint: 解決待ち
|
|
851
853
|
resolution_rate: 解決率
|
data/config/locales/pl.yml
CHANGED
|
@@ -611,6 +611,7 @@ pl:
|
|
|
611
611
|
snapshot_stale: z wcześniejszego wystąpienia — późniejsze nie przyniosły kontekstu
|
|
612
612
|
snapshot_fidelity_lite: ograniczone przechwytywanie (ochrona przed storm odrzuciła payloady kontekstu)
|
|
613
613
|
snapshot_fidelity_minimal: zliczone podczas storm — nie przechwycono żadnego kontekstu
|
|
614
|
+
snapshot_fidelity_partial: mieszany zrzut — część pól pochodzi z wcześniejszego wystąpienia
|
|
614
615
|
snapshot_unknown: przechwycono, zanim RED zaczął zapisywać pochodzenie migawek
|
|
615
616
|
similar_errors:
|
|
616
617
|
title: Podobne błędy
|
|
@@ -978,6 +979,7 @@ pl:
|
|
|
978
979
|
rate_per_hour: "%{value}/godz."
|
|
979
980
|
affected_users_incomplete: co najmniej — ochrona przed storm odrzuciła szczegóły poszczególnych zdarzeń
|
|
980
981
|
data_unavailable: Statystyki są obecnie niedostępne — poniższe liczby nie są pomiarem.
|
|
982
|
+
event_timing_incomplete: "Znaczniki czasu są niepełne — ochrona przed burzą je odrzuciła; sumy pozostają dokładne."
|
|
981
983
|
unresolved: Nierozwiązane
|
|
982
984
|
unresolved_hint: Oczekują na rozwiązanie
|
|
983
985
|
resolution_rate: Wskaźnik rozwiązań
|
data/config/locales/pt-BR.yml
CHANGED
|
@@ -606,6 +606,7 @@ pt-BR:
|
|
|
606
606
|
snapshot_stale: de uma ocorrência anterior — as posteriores não trouxeram contexto
|
|
607
607
|
snapshot_fidelity_lite: captura reduzida (a proteção contra storms descartou os payloads de contexto)
|
|
608
608
|
snapshot_fidelity_minimal: contabilizado durante um storm — nenhum contexto foi capturado
|
|
609
|
+
snapshot_fidelity_partial: snapshot misto — alguns campos vêm de uma ocorrência anterior
|
|
609
610
|
snapshot_unknown: capturado antes de o RED registrar a procedência dos instantâneos
|
|
610
611
|
similar_errors:
|
|
611
612
|
title: Erros semelhantes
|
|
@@ -966,6 +967,7 @@ pt-BR:
|
|
|
966
967
|
rate_per_hour: "%{value}/h"
|
|
967
968
|
affected_users_incomplete: no mínimo — a proteção contra storms descartou o detalhe por evento
|
|
968
969
|
data_unavailable: As estatísticas estão indisponíveis no momento — os números abaixo não são uma medição.
|
|
970
|
+
event_timing_incomplete: "Os horários estão incompletos — a proteção contra tempestades descartou os carimbos de tempo; os totais continuam exatos."
|
|
969
971
|
unresolved: Não resolvidos
|
|
970
972
|
unresolved_hint: Aguardando resolução
|
|
971
973
|
resolution_rate: Taxa de resolução
|
data/config/locales/ru.yml
CHANGED
|
@@ -614,6 +614,7 @@ ru:
|
|
|
614
614
|
snapshot_stale: из более раннего появления — последующие не содержали контекста
|
|
615
615
|
snapshot_fidelity_lite: сокращённый сбор (защита от storm отбросила payload контекста)
|
|
616
616
|
snapshot_fidelity_minimal: учтено во время storm — контекст не собирался
|
|
617
|
+
snapshot_fidelity_partial: смешанный снимок — часть полей осталась от более раннего события
|
|
617
618
|
snapshot_unknown: получен до того, как RED начал записывать происхождение снимков
|
|
618
619
|
similar_errors:
|
|
619
620
|
title: Похожие ошибки
|
|
@@ -981,6 +982,7 @@ ru:
|
|
|
981
982
|
rate_per_hour: "%{value}/ч"
|
|
982
983
|
affected_users_incomplete: не менее — защита от storm отбросила детализацию по событиям
|
|
983
984
|
data_unavailable: Статистика сейчас недоступна — приведённые ниже числа не являются измерением.
|
|
985
|
+
event_timing_incomplete: "Отметки времени неполные — защита от шторма отбросила их; итоговые значения остаются точными."
|
|
984
986
|
unresolved: Не решено
|
|
985
987
|
unresolved_hint: Ожидают решения
|
|
986
988
|
resolution_rate: Доля решённых
|
data/config/locales/uk.yml
CHANGED
|
@@ -612,6 +612,7 @@ uk:
|
|
|
612
612
|
snapshot_stale: з ранішого випадку — подальші не містили контексту
|
|
613
613
|
snapshot_fidelity_lite: скорочений збір (захист від storm відкинув payload контексту)
|
|
614
614
|
snapshot_fidelity_minimal: враховано під час storm — контекст не збирався
|
|
615
|
+
snapshot_fidelity_partial: змішаний знімок — частина полів залишилася від давнішої події
|
|
615
616
|
snapshot_unknown: отримано до того, як RED почав записувати походження знімків
|
|
616
617
|
similar_errors:
|
|
617
618
|
title: Схожі помилки
|
|
@@ -978,6 +979,7 @@ uk:
|
|
|
978
979
|
rate_per_hour: "%{value}/год"
|
|
979
980
|
affected_users_incomplete: щонайменше — захист від storm відкинув деталізацію за подіями
|
|
980
981
|
data_unavailable: Статистика зараз недоступна — наведені нижче числа не є вимірюванням.
|
|
982
|
+
event_timing_incomplete: "Позначки часу неповні — захист від шторму відкинув їх; підсумкові значення залишаються точними."
|
|
981
983
|
unresolved: Не вирішено
|
|
982
984
|
unresolved_hint: Очікують вирішення
|
|
983
985
|
resolution_rate: Частка вирішених
|
data/config/locales/zh-CN.yml
CHANGED
|
@@ -529,6 +529,7 @@ zh-CN:
|
|
|
529
529
|
snapshot_stale: 来自更早的一次发生 — 之后的发生未携带上下文
|
|
530
530
|
snapshot_fidelity_lite: 已精简的捕获(storm 保护丢弃了上下文 payload)
|
|
531
531
|
snapshot_fidelity_minimal: 在 storm 期间计数 — 未捕获任何上下文
|
|
532
|
+
snapshot_fidelity_partial: 混合快照 — 部分字段来自更早的一次发生
|
|
532
533
|
snapshot_unknown: 在 RED 记录快照来源之前捕获
|
|
533
534
|
similar_errors:
|
|
534
535
|
title: 相似错误
|
|
@@ -842,6 +843,7 @@ zh-CN:
|
|
|
842
843
|
rate_per_hour: "%{value}/小时"
|
|
843
844
|
affected_users_incomplete: 至少 — storm 保护丢弃了逐事件的明细
|
|
844
845
|
data_unavailable: 统计数据当前不可用 — 下方数字并非测量结果。
|
|
846
|
+
event_timing_incomplete: "事件时间记录不完整 — 风暴保护丢弃了时间戳;总数仍然准确。"
|
|
845
847
|
unresolved: 未解决
|
|
846
848
|
unresolved_hint: 等待处理
|
|
847
849
|
resolution_rate: 解决率
|