rails_error_dashboard 0.14.0 → 0.14.2
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/controllers/rails_error_dashboard/application_controller.rb +9 -0
- data/config/locales/ja.yml +5 -5
- data/lib/rails_error_dashboard/configuration.rb +72 -20
- data/lib/rails_error_dashboard/queries/error_correlation.rb +36 -27
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +119 -108
- data/lib/rails_error_dashboard/queries/recurring_issues.rb +44 -25
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +29 -10
- data/lib/rails_error_dashboard/services/digest_builder.rb +44 -15
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/tasks/error_dashboard.rake +9 -4
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93c0e9e0f21298eb85db1d661793709d60158c5f2c6f85b164da36b4593525ee
|
|
4
|
+
data.tar.gz: 37bae1ddaca2d502e434e0b4e6e95f6c755619598dec20f08495ce13fd0c286a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c6be20b1be293c069516ed0f5f6fc19e46203224bb9aaad6be1c645f45ebb3455e03e6b3fef8d8738e4cc658dc928cd56c9f2d403d270ec6caaaa74411074b7
|
|
7
|
+
data.tar.gz: 7c1c344be4398b47faf98de6a9be675f41a86878b49404d76b73d1a5be91f046ded677c70ebb9c1f4118e8736873ea491edcfed07b6520465049b9ecb3e1066b
|
|
@@ -314,6 +314,15 @@ module RailsErrorDashboard
|
|
|
314
314
|
expected_password = RailsErrorDashboard.configuration.dashboard_password
|
|
315
315
|
next false if expected_username.nil? || expected_password.nil?
|
|
316
316
|
|
|
317
|
+
# A blank credential must deny everyone too: "" would equal an empty login.
|
|
318
|
+
next false if RailsErrorDashboard::Configuration.blank_credential?(expected_username) ||
|
|
319
|
+
RailsErrorDashboard::Configuration.blank_credential?(expected_password)
|
|
320
|
+
|
|
321
|
+
# The boot check refuses the published credentials outside development
|
|
322
|
+
# and test, but it is skipped while SECRET_KEY_BASE_DUMMY is set. Refuse
|
|
323
|
+
# them here as well, so that variable left set at runtime reopens nothing.
|
|
324
|
+
next false if RailsErrorDashboard.configuration.refuse_default_credentials?
|
|
325
|
+
|
|
317
326
|
# Non-short-circuit & on purpose: both comparisons always run, so the
|
|
318
327
|
# response time does not reveal which half was wrong.
|
|
319
328
|
ActiveSupport::SecurityUtils.secure_compare(username.to_s, expected_username.to_s) &
|
data/config/locales/ja.yml
CHANGED
|
@@ -23,7 +23,7 @@ ja:
|
|
|
23
23
|
'30': 30日間
|
|
24
24
|
'90': 90日間
|
|
25
25
|
frame_category:
|
|
26
|
-
app:
|
|
26
|
+
app: アプリケーションコード
|
|
27
27
|
gem: Gem
|
|
28
28
|
framework: Rails フレームワーク
|
|
29
29
|
ruby_core: Ruby コア
|
|
@@ -520,7 +520,7 @@ ja:
|
|
|
520
520
|
user_agent: 'User Agent:'
|
|
521
521
|
ip_address: 'IP アドレス:'
|
|
522
522
|
replay: 'リクエストの再現:'
|
|
523
|
-
raw_user_agent: User Agent
|
|
523
|
+
raw_user_agent: User Agent 文字列
|
|
524
524
|
duration_seconds: "%{value}秒"
|
|
525
525
|
duration_ms: "%{value}ミリ秒"
|
|
526
526
|
copy_curl: curl としてコピー
|
|
@@ -530,7 +530,7 @@ ja:
|
|
|
530
530
|
snapshot: 'スナップショット:'
|
|
531
531
|
snapshot_captured_html: '%{time} に取得'
|
|
532
532
|
snapshot_stale: より前の発生から取得 — それ以降の発生はコンテキストを伴いませんでした
|
|
533
|
-
snapshot_fidelity_lite:
|
|
533
|
+
snapshot_fidelity_lite: 限定的な情報取得(storm 保護によりコンテキストの payload を省略)
|
|
534
534
|
snapshot_fidelity_minimal: storm 中にカウントされました — コンテキストは取得されていません
|
|
535
535
|
snapshot_fidelity_partial: 混在したスナップショット — 一部のフィールドは以前の発生時のものです
|
|
536
536
|
snapshot_unknown: RED がスナップショットの出所を記録する前に取得されました
|
|
@@ -667,10 +667,10 @@ ja:
|
|
|
667
667
|
backtrace: Backtrace
|
|
668
668
|
backtrace:
|
|
669
669
|
label: 'Backtrace:'
|
|
670
|
-
frame_counts: "(
|
|
670
|
+
frame_counts: "(アプリケーションコード%{app}件、フレームワーク/gem %{framework}件)"
|
|
671
671
|
copy_full: Backtrace 全体をコピー
|
|
672
672
|
copy_full_title: Backtrace 全体をコピー
|
|
673
|
-
your_code:
|
|
673
|
+
your_code: アプリケーションコード
|
|
674
674
|
framework_code: フレームワーク・gem のコード
|
|
675
675
|
frames:
|
|
676
676
|
other: "%{count}フレーム"
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
class Configuration
|
|
5
|
+
# The built-in Basic auth credentials. They are published (README, demo,
|
|
6
|
+
# gemspec), so outside development and test they are refused unless
|
|
7
|
+
# ERROR_DASHBOARD_PASSWORD explicitly supplies the password.
|
|
8
|
+
DEFAULT_DASHBOARD_USERNAME = "gandalf"
|
|
9
|
+
DEFAULT_DASHBOARD_PASSWORD = "youshallnotpass"
|
|
10
|
+
|
|
5
11
|
# Dashboard authentication (always required)
|
|
6
12
|
attr_accessor :dashboard_username
|
|
7
13
|
attr_accessor :dashboard_password
|
|
@@ -269,8 +275,8 @@ module RailsErrorDashboard
|
|
|
269
275
|
|
|
270
276
|
def initialize
|
|
271
277
|
# Default values - Authentication is ALWAYS required
|
|
272
|
-
@dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER",
|
|
273
|
-
@dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD",
|
|
278
|
+
@dashboard_username = ENV.fetch("ERROR_DASHBOARD_USER", DEFAULT_DASHBOARD_USERNAME)
|
|
279
|
+
@dashboard_password = ENV.fetch("ERROR_DASHBOARD_PASSWORD", DEFAULT_DASHBOARD_PASSWORD)
|
|
274
280
|
@authenticate_with = nil
|
|
275
281
|
|
|
276
282
|
@user_model = nil # Auto-detect if not set
|
|
@@ -536,12 +542,16 @@ module RailsErrorDashboard
|
|
|
536
542
|
# (GHSA-qhgm-3pxf-mvc6). Every future environment name a team invents is
|
|
537
543
|
# now refused by default and has to be added here on purpose.
|
|
538
544
|
#
|
|
539
|
-
# Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
+
# Skip during asset precompilation (SECRET_KEY_BASE_DUMMY=1) — ENV vars aren't available at build time.
|
|
546
|
+
# The login refuses the same credentials on its own, so SECRET_KEY_BASE_DUMMY
|
|
547
|
+
# left set at runtime does not reopen them.
|
|
548
|
+
if refuse_default_credentials? && ENV["SECRET_KEY_BASE_DUMMY"].blank?
|
|
549
|
+
reason = if credentials_problem == :blank
|
|
550
|
+
"the dashboard username or password is blank"
|
|
551
|
+
else
|
|
552
|
+
"the dashboard password is the published default and was not set by ERROR_DASHBOARD_PASSWORD"
|
|
553
|
+
end
|
|
554
|
+
errors << "Default or blank credentials cannot be used in #{Rails.env}: #{reason}. Only development and test may run on the built-in credentials. Set ERROR_DASHBOARD_USER and ERROR_DASHBOARD_PASSWORD environment variables and make sure the initializer does not overwrite them, or use authenticate_with for custom auth."
|
|
545
555
|
end
|
|
546
556
|
|
|
547
557
|
# Validate sampling_rate (must be between 0.0 and 1.0)
|
|
@@ -925,23 +935,65 @@ module RailsErrorDashboard
|
|
|
925
935
|
list.empty? ? nil : list
|
|
926
936
|
end
|
|
927
937
|
|
|
928
|
-
#
|
|
938
|
+
# Why the Basic auth credentials cannot be trusted outside development and
|
|
939
|
+
# test, or nil when they can.
|
|
929
940
|
#
|
|
930
|
-
#
|
|
931
|
-
#
|
|
932
|
-
#
|
|
941
|
+
# - :blank - the username or password is empty or whitespace. An explicitly
|
|
942
|
+
# empty variable is not a choice: a compose file passing an unset variable
|
|
943
|
+
# through produces exactly that, and "" would match an empty login.
|
|
944
|
+
# - :published_password - the password is the published default and did not
|
|
945
|
+
# come from ERROR_DASHBOARD_PASSWORD. Setting that variable, even to the
|
|
946
|
+
# default, is a deliberate choice (the live demo runs that way). It has to
|
|
947
|
+
# be THAT variable: setting only ERROR_DASHBOARD_USER used to leave the
|
|
948
|
+
# password on the published default, and an initializer can hardcode it.
|
|
933
949
|
#
|
|
934
|
-
#
|
|
935
|
-
|
|
936
|
-
|
|
950
|
+
# Both compare values the way the login does, with to_s: a Symbol holding the
|
|
951
|
+
# published password logs in just the same.
|
|
952
|
+
#
|
|
953
|
+
# @return [Symbol, nil]
|
|
954
|
+
def credentials_problem
|
|
955
|
+
return :blank if self.class.blank_credential?(dashboard_username) ||
|
|
956
|
+
self.class.blank_credential?(dashboard_password)
|
|
957
|
+
|
|
958
|
+
if dashboard_password.to_s == DEFAULT_DASHBOARD_PASSWORD &&
|
|
959
|
+
ENV["ERROR_DASHBOARD_PASSWORD"] != DEFAULT_DASHBOARD_PASSWORD
|
|
960
|
+
return :published_password
|
|
961
|
+
end
|
|
937
962
|
|
|
938
|
-
|
|
939
|
-
|
|
963
|
+
nil
|
|
964
|
+
end
|
|
940
965
|
|
|
941
|
-
|
|
942
|
-
|
|
966
|
+
# True for a credential that cannot be a secret: nil, empty, or only
|
|
967
|
+
# whitespace, including Unicode whitespace such as a no-break space, which
|
|
968
|
+
# String#strip leaves in place. The boot check and the login both use it.
|
|
969
|
+
#
|
|
970
|
+
# @return [Boolean]
|
|
971
|
+
def self.blank_credential?(value)
|
|
972
|
+
value.to_s.blank?
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Check if basic auth is active with blank credentials or the published
|
|
976
|
+
# default password (see #credentials_problem)
|
|
977
|
+
#
|
|
978
|
+
# Basic auth is active whenever authenticate_with is falsy, not only nil:
|
|
979
|
+
# the login falls back to it for `false` too, which is what
|
|
980
|
+
# `Rails.env.production? && -> { ... }` evaluates to in staging.
|
|
981
|
+
#
|
|
982
|
+
# @return [Boolean]
|
|
983
|
+
def default_credentials?
|
|
984
|
+
!authenticate_with && !credentials_problem.nil?
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# True where default_credentials? has to stop the dashboard: anywhere that is
|
|
988
|
+
# not local development or test. The boot check and the login both use it,
|
|
989
|
+
# so the login still refuses when the boot check was skipped.
|
|
990
|
+
#
|
|
991
|
+
# @return [Boolean]
|
|
992
|
+
def refuse_default_credentials?
|
|
993
|
+
return false unless default_credentials?
|
|
994
|
+
return false unless defined?(Rails) && Rails.respond_to?(:env)
|
|
943
995
|
|
|
944
|
-
|
|
996
|
+
!Rails.env.development? && !Rails.env.test?
|
|
945
997
|
end
|
|
946
998
|
|
|
947
999
|
# Resolve the effective issue tracker provider (auto-detect from git_repository_url).
|
|
@@ -224,16 +224,14 @@ module RailsErrorDashboard
|
|
|
224
224
|
previous_start = @start_date
|
|
225
225
|
previous_end = current_start
|
|
226
226
|
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# the one panel
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
previous_errors =
|
|
235
|
-
.where("occurred_at < ?", previous_end)
|
|
236
|
-
.count
|
|
227
|
+
# EVENTS in each period, counted where they happened, over app_scope
|
|
228
|
+
# (which carries the application filter -- querying ErrorLog directly
|
|
229
|
+
# once made this the one panel that counted every app). Counting groups
|
|
230
|
+
# BORN in each period measured how many new errors appeared, not whether
|
|
231
|
+
# the application errors more or less: a chronic error that fired all
|
|
232
|
+
# month counted as zero in both halves.
|
|
233
|
+
current_errors = Queries::EventVolume.in_window(app_scope, current_start)
|
|
234
|
+
previous_errors = Queries::EventVolume.in_window(app_scope, previous_start, previous_end)
|
|
237
235
|
|
|
238
236
|
change_percentage = if previous_errors > 0
|
|
239
237
|
((current_errors - previous_errors).to_f / previous_errors * 100).round(1)
|
|
@@ -261,25 +259,28 @@ module RailsErrorDashboard
|
|
|
261
259
|
# Get top error types by platform
|
|
262
260
|
# Shows which errors are platform-specific vs cross-platform
|
|
263
261
|
# @return [Hash] Platform => top error types
|
|
262
|
+
#
|
|
263
|
+
# EVENT ranking: each platform's error types by their events in the
|
|
264
|
+
# window, and `also_on` from where else that type had events. Counting
|
|
265
|
+
# groups first seen in the window left out every chronic error, and
|
|
266
|
+
# ranked a type with many one-off groups above one that fired all day.
|
|
264
267
|
def platform_specific_errors
|
|
265
|
-
platforms =
|
|
268
|
+
platforms = app_scope.distinct.pluck(:platform).compact
|
|
269
|
+
counts_by_platform = platforms.index_with do |platform|
|
|
270
|
+
Queries::EventVolume.by_group_attribute(app_scope.where(platform: platform), :error_type, @start_date)
|
|
271
|
+
.select { |_, count| count.positive? }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
counts_by_platform.each_with_object({}) do |(platform, counts_by_type), result|
|
|
275
|
+
next if counts_by_type.empty?
|
|
266
276
|
|
|
267
|
-
|
|
268
|
-
platform_errors = base_query.where(platform: platform)
|
|
269
|
-
top_errors = platform_errors
|
|
270
|
-
.group(:error_type)
|
|
271
|
-
.count
|
|
272
|
-
.sort_by { |_, count| -count }
|
|
273
|
-
.first(5)
|
|
277
|
+
top_errors = counts_by_type.sort_by { |error_type, count| [ -count, error_type.to_s ] }.first(5)
|
|
274
278
|
|
|
275
279
|
result[platform] = top_errors.map do |error_type, count|
|
|
276
|
-
#
|
|
277
|
-
other_platforms =
|
|
278
|
-
.
|
|
279
|
-
.
|
|
280
|
-
.distinct
|
|
281
|
-
.pluck(:platform)
|
|
282
|
-
.compact
|
|
280
|
+
# Other platforms where this type had events in the window
|
|
281
|
+
other_platforms = counts_by_platform
|
|
282
|
+
.select { |other, other_counts| other != platform && other_counts.key?(error_type) }
|
|
283
|
+
.keys
|
|
283
284
|
|
|
284
285
|
{
|
|
285
286
|
error_type: error_type,
|
|
@@ -293,12 +294,20 @@ module RailsErrorDashboard
|
|
|
293
294
|
|
|
294
295
|
private
|
|
295
296
|
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
# Every group of the application, NOT cut by date -- what EventVolume
|
|
298
|
+
# must be handed, or it never sees an older group's recurrences.
|
|
299
|
+
def app_scope
|
|
300
|
+
scope = ErrorLog.all
|
|
298
301
|
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
299
302
|
scope
|
|
300
303
|
end
|
|
301
304
|
|
|
305
|
+
# Groups FIRST SEEN in the window. Release attribution (errors by
|
|
306
|
+
# version / git SHA) is first-seen by design, so those figures use this.
|
|
307
|
+
def base_query
|
|
308
|
+
app_scope.where("occurred_at >= ?", @start_date)
|
|
309
|
+
end
|
|
310
|
+
|
|
302
311
|
# Check if app_version column exists
|
|
303
312
|
def has_version_column?
|
|
304
313
|
ErrorLog.column_names.include?("app_version")
|
|
@@ -12,6 +12,24 @@ module RailsErrorDashboard
|
|
|
12
12
|
# - Platform stability scores
|
|
13
13
|
# - Cross-platform errors
|
|
14
14
|
#
|
|
15
|
+
# Two counting units, kept distinct on purpose -- the same split as
|
|
16
|
+
# AnalyticsStats, for the same reason.
|
|
17
|
+
#
|
|
18
|
+
# EVENT figures -> Queries::EventVolume: how much erroring happened in
|
|
19
|
+
# the window. Error rate, daily trend, severity
|
|
20
|
+
# distribution, cross-platform totals, the top-errors
|
|
21
|
+
# ranking, and the health card's total, critical count
|
|
22
|
+
# and velocity.
|
|
23
|
+
# GROUP figures -> groups FIRST SEEN in the window: the state of
|
|
24
|
+
# distinct errors. Unresolved count, resolution rate and
|
|
25
|
+
# resolution time. A group is what gets resolved; an
|
|
26
|
+
# event cannot be.
|
|
27
|
+
#
|
|
28
|
+
# An ErrorLog row is a GROUP and its occurred_at is FIRST-SEEN. Selecting
|
|
29
|
+
# groups by it and counting them made a group born last month that recurs
|
|
30
|
+
# today invisible here -- zero errors on this page (and on the Overview's
|
|
31
|
+
# platform health card) while the Overview's own total counted the event.
|
|
32
|
+
#
|
|
15
33
|
# @example
|
|
16
34
|
# comparison = PlatformComparison.new(days: 7)
|
|
17
35
|
# comparison.error_rate_by_platform
|
|
@@ -29,7 +47,9 @@ module RailsErrorDashboard
|
|
|
29
47
|
|
|
30
48
|
private
|
|
31
49
|
|
|
32
|
-
#
|
|
50
|
+
# Every group of the selected application, NOT cut by date. EventVolume
|
|
51
|
+
# must be handed this: a scope already filtered by first-seen drops the
|
|
52
|
+
# events of every older group that recurred inside the window.
|
|
33
53
|
# @return [ActiveRecord::Relation]
|
|
34
54
|
def base_scope
|
|
35
55
|
scope = ErrorLog.all
|
|
@@ -37,38 +57,47 @@ module RailsErrorDashboard
|
|
|
37
57
|
scope
|
|
38
58
|
end
|
|
39
59
|
|
|
60
|
+
def platforms
|
|
61
|
+
@platforms ||= base_scope.distinct.pluck(:platform).compact
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def volume
|
|
65
|
+
@volume ||= Queries::EventVolume.new(base_scope, @start_date)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# platform => { error_type => events in the window }.
|
|
69
|
+
#
|
|
70
|
+
# Severity, critical counts and cross-platform totals all fold from this
|
|
71
|
+
# one breakdown, so they cannot drift from each other, and each platform's
|
|
72
|
+
# parts sum to its error rate (the same primitive, exhaustively grouped).
|
|
73
|
+
def type_volume_by_platform
|
|
74
|
+
@type_volume_by_platform ||= platforms.index_with do |platform|
|
|
75
|
+
Queries::EventVolume.by_group_attribute(base_scope.where(platform: platform), :error_type, @start_date)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
40
79
|
public
|
|
41
80
|
|
|
42
|
-
#
|
|
43
|
-
# @return [Hash] Platform name =>
|
|
81
|
+
# Events per platform in the window.
|
|
82
|
+
# @return [Hash] Platform name => event count
|
|
44
83
|
def error_rate_by_platform
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
.count
|
|
84
|
+
# select, not the raw breakdown: that is a Hash.new(0), and a platform
|
|
85
|
+
# with no events must read as absent (nil), not as a silent zero.
|
|
86
|
+
@error_rate_by_platform ||= volume.by_group_attribute(:platform).select { |_, count| count.positive? }
|
|
49
87
|
end
|
|
50
88
|
|
|
51
|
-
#
|
|
89
|
+
# Events per platform, by severity of their error type.
|
|
52
90
|
# @return [Hash] Platform => { severity => count }
|
|
53
91
|
def severity_distribution_by_platform
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
errors = base_scope
|
|
58
|
-
.where(platform: platform)
|
|
59
|
-
.where("occurred_at >= ?", @start_date)
|
|
60
|
-
|
|
61
|
-
# Calculate severity in Ruby since it's a method, not a column
|
|
62
|
-
severity_counts = Hash.new(0)
|
|
63
|
-
errors.each do |error|
|
|
64
|
-
severity_counts[error.severity] += 1
|
|
92
|
+
type_volume_by_platform.transform_values do |counts_by_type|
|
|
93
|
+
counts_by_type.each_with_object(Hash.new(0)) do |(error_type, count), severities|
|
|
94
|
+
severities[Services::SeverityClassifier.classify(error_type)] += count
|
|
65
95
|
end
|
|
66
|
-
|
|
67
|
-
result[platform] = severity_counts
|
|
68
96
|
end
|
|
69
97
|
end
|
|
70
98
|
|
|
71
99
|
# Get average resolution time by platform
|
|
100
|
+
# GROUP figure: a resolution belongs to a group, selected by first-seen.
|
|
72
101
|
# @return [Hash] Platform => average hours to resolve
|
|
73
102
|
def resolution_time_by_platform
|
|
74
103
|
platforms = base_scope.distinct.pluck(:platform).compact
|
|
@@ -90,28 +119,38 @@ module RailsErrorDashboard
|
|
|
90
119
|
end
|
|
91
120
|
end
|
|
92
121
|
|
|
93
|
-
#
|
|
122
|
+
# Top 10 groups per platform, ranked by their events in the window.
|
|
123
|
+
#
|
|
124
|
+
# :occurrence_count is the group's count WITHIN the window, not its
|
|
125
|
+
# lifetime total -- the key is kept so the view and callers are
|
|
126
|
+
# unchanged. The per-group breakdown holds one integer pair per group
|
|
127
|
+
# with events in the window; only the top ten rows are loaded.
|
|
94
128
|
# @return [Hash] Platform => Array of error hashes
|
|
95
129
|
def top_errors_by_platform
|
|
96
|
-
platforms = base_scope.distinct.pluck(:platform).compact
|
|
97
|
-
|
|
98
130
|
platforms.each_with_object({}) do |platform, result|
|
|
99
|
-
|
|
100
|
-
.where(platform: platform)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
131
|
+
events_by_group = Queries::EventVolume.by_group_attribute(
|
|
132
|
+
base_scope.where(platform: platform), :id, @start_date
|
|
133
|
+
)
|
|
134
|
+
top = events_by_group.select { |_, count| count.positive? }
|
|
135
|
+
.sort_by { |id, count| [ -count, id ] }
|
|
136
|
+
.first(10)
|
|
137
|
+
groups = ErrorLog.where(id: top.map(&:first))
|
|
138
|
+
.select(:id, :error_type, :message, :occurred_at)
|
|
139
|
+
.index_by(&:id)
|
|
140
|
+
|
|
141
|
+
result[platform] = top.filter_map do |id, count|
|
|
142
|
+
error = groups[id]
|
|
143
|
+
next unless error
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
id: error.id,
|
|
147
|
+
error_type: error.error_type,
|
|
148
|
+
message: error.message&.truncate(100),
|
|
149
|
+
severity: error.severity, # Calls the method
|
|
150
|
+
occurrence_count: count,
|
|
151
|
+
occurred_at: error.occurred_at
|
|
152
|
+
}
|
|
153
|
+
end
|
|
115
154
|
end
|
|
116
155
|
end
|
|
117
156
|
|
|
@@ -141,98 +180,70 @@ module RailsErrorDashboard
|
|
|
141
180
|
end
|
|
142
181
|
end
|
|
143
182
|
|
|
144
|
-
#
|
|
183
|
+
# Error types with events on more than one platform in the window.
|
|
145
184
|
# @return [Array<Hash>] Errors with their platforms
|
|
146
185
|
def cross_platform_errors
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
.pluck(:error_type, :platform)
|
|
154
|
-
|
|
155
|
-
# Group by error_type to find those on multiple platforms
|
|
156
|
-
errors_by_type = error_types_with_platforms.group_by { |error_type, _| error_type }
|
|
157
|
-
|
|
158
|
-
errors_by_type
|
|
159
|
-
.select { |_, platforms| platforms.map(&:last).uniq.count > 1 }
|
|
160
|
-
.map do |error_type, platform_pairs|
|
|
161
|
-
platforms = platform_pairs.map(&:last).uniq
|
|
162
|
-
total_count = base_scope
|
|
163
|
-
.where(error_type: error_type, platform: platforms)
|
|
164
|
-
.where("occurred_at >= ?", @start_date)
|
|
165
|
-
.sum(:occurrence_count)
|
|
186
|
+
by_type = Hash.new { |hash, error_type| hash[error_type] = {} }
|
|
187
|
+
type_volume_by_platform.each do |platform, counts_by_type|
|
|
188
|
+
counts_by_type.each do |error_type, count|
|
|
189
|
+
by_type[error_type][platform] = count if count.positive?
|
|
190
|
+
end
|
|
191
|
+
end
|
|
166
192
|
|
|
193
|
+
by_type
|
|
194
|
+
.select { |_, breakdown| breakdown.size > 1 }
|
|
195
|
+
.map do |error_type, breakdown|
|
|
167
196
|
{
|
|
168
197
|
error_type: error_type,
|
|
169
|
-
platforms:
|
|
170
|
-
total_occurrences:
|
|
171
|
-
platform_breakdown:
|
|
172
|
-
breakdown[platform] = base_scope
|
|
173
|
-
.where(error_type: error_type, platform: platform)
|
|
174
|
-
.where("occurred_at >= ?", @start_date)
|
|
175
|
-
.sum(:occurrence_count)
|
|
176
|
-
end
|
|
198
|
+
platforms: breakdown.keys.sort,
|
|
199
|
+
total_occurrences: breakdown.values.sum,
|
|
200
|
+
platform_breakdown: breakdown
|
|
177
201
|
}
|
|
178
202
|
end
|
|
179
203
|
.sort_by { |error| -error[:total_occurrences] }
|
|
180
204
|
end
|
|
181
205
|
|
|
182
|
-
#
|
|
206
|
+
# Events per day per platform, on the day each event HAPPENED.
|
|
207
|
+
#
|
|
208
|
+
# The zero-filled date range is preserved: the chart needs a point for
|
|
209
|
+
# every day in the window, not only the days that had errors.
|
|
183
210
|
# @return [Hash] Platform => { date => count }
|
|
184
211
|
def daily_trend_by_platform
|
|
185
|
-
|
|
212
|
+
days = (@start_date.to_date..Date.current)
|
|
186
213
|
|
|
187
|
-
platforms.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
.where("occurred_at >= ?", @start_date)
|
|
191
|
-
.group_by_day(:occurred_at, range: @start_date..Time.current)
|
|
192
|
-
.count
|
|
214
|
+
platforms.index_with do |platform|
|
|
215
|
+
counts = Queries::EventVolume.by_day(base_scope.where(platform: platform), @start_date)
|
|
216
|
+
days.to_h { |day| [ day, counts[day] || 0 ] }
|
|
193
217
|
end
|
|
194
218
|
end
|
|
195
219
|
|
|
196
220
|
# Get platform health summary
|
|
197
221
|
# @return [Hash] Platform => health metrics
|
|
198
222
|
def platform_health_summary
|
|
199
|
-
platforms = base_scope.distinct.pluck(:platform).compact
|
|
200
223
|
error_rates = error_rate_by_platform
|
|
201
224
|
stability_scores = platform_stability_scores
|
|
225
|
+
severities = severity_distribution_by_platform
|
|
226
|
+
midpoint = @start_date + (@days / 2.0).days
|
|
202
227
|
|
|
203
228
|
platforms.each_with_object({}) do |platform, result|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
# Count critical errors by checking severity method
|
|
207
|
-
critical_errors = base_scope
|
|
208
|
-
.where(platform: platform)
|
|
209
|
-
.where("occurred_at >= ?", @start_date)
|
|
210
|
-
.select { |error| error.severity == :critical }
|
|
211
|
-
.count
|
|
229
|
+
platform_scope = base_scope.where(platform: platform)
|
|
230
|
+
groups_in_window = platform_scope.where("occurred_at >= ?", @start_date)
|
|
212
231
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
.where(platform: platform)
|
|
229
|
-
.where("occurred_at >= ? AND occurred_at < ?", @start_date, @start_date + (@days / 2.0).days)
|
|
230
|
-
.count
|
|
231
|
-
|
|
232
|
-
second_half = base_scope
|
|
233
|
-
.where(platform: platform)
|
|
234
|
-
.where("occurred_at >= ?", @start_date + (@days / 2.0).days)
|
|
235
|
-
.count
|
|
232
|
+
# EVENT figures: part of the same volume as the error rate.
|
|
233
|
+
total_errors = error_rates[platform] || 0
|
|
234
|
+
critical_errors = severities.fetch(platform, {})[:critical] || 0
|
|
235
|
+
|
|
236
|
+
# GROUP figures. The resolution rate divides groups by groups; divided
|
|
237
|
+
# by the event total it would mix units and stop meaning anything.
|
|
238
|
+
unresolved_errors = groups_in_window.where(resolved_at: nil).count
|
|
239
|
+
resolved_errors = groups_in_window.where.not(resolved_at: nil).count
|
|
240
|
+
total_groups = unresolved_errors + resolved_errors
|
|
241
|
+
resolution_rate = total_groups.positive? ? ((resolved_errors.to_f / total_groups) * 100).round(1) : 0.0
|
|
242
|
+
|
|
243
|
+
# Error velocity: events in the second half of the window against the
|
|
244
|
+
# first, each counted where it happened.
|
|
245
|
+
first_half = Queries::EventVolume.in_window(platform_scope, @start_date, midpoint)
|
|
246
|
+
second_half = Queries::EventVolume.in_window(platform_scope, midpoint)
|
|
236
247
|
|
|
237
248
|
velocity = first_half.positive? ? (((second_half - first_half).to_f / first_half) * 100).round(1) : 0.0
|
|
238
249
|
|
|
@@ -5,6 +5,9 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Query: Analyze recurring and persistent errors
|
|
6
6
|
# Returns data about high-frequency errors, persistent issues, and cyclical patterns
|
|
7
7
|
class RecurringIssues
|
|
8
|
+
# Minimum events IN THE WINDOW for a group to count as high frequency.
|
|
9
|
+
HIGH_FREQUENCY_THRESHOLD = 10
|
|
10
|
+
|
|
8
11
|
def self.call(days = 30, application_id: nil)
|
|
9
12
|
new(days, application_id: application_id).call
|
|
10
13
|
end
|
|
@@ -25,37 +28,53 @@ module RailsErrorDashboard
|
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
# Every group of the application, NOT cut by date -- what EventVolume
|
|
32
|
+
# must be handed, or it never sees an older group's recurrences.
|
|
33
|
+
def app_scope
|
|
34
|
+
scope = ErrorLog.all
|
|
30
35
|
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
31
36
|
scope
|
|
32
37
|
end
|
|
33
38
|
|
|
39
|
+
# Groups FIRST SEEN in the window, for the group lists below.
|
|
40
|
+
def base_query
|
|
41
|
+
app_scope.where("occurred_at >= ?", @start_date)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# EVENT figure: error types whose groups fired more than
|
|
45
|
+
# HIGH_FREQUENCY_THRESHOLD times in the window, by events in the window.
|
|
46
|
+
#
|
|
47
|
+
# Selecting groups by first-seen and summing their LIFETIME
|
|
48
|
+
# occurrence_count listed only errors born in the window -- a chronic
|
|
49
|
+
# error that fired all month was never "high frequency" once it was a
|
|
50
|
+
# month old. The threshold stays per group (not per type) so eleven
|
|
51
|
+
# one-off groups of one type do not qualify; it now counts this window.
|
|
34
52
|
def high_frequency_errors
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
.group(:error_type)
|
|
39
|
-
.select("error_type,
|
|
40
|
-
SUM(occurrence_count) as total_occurrences,
|
|
41
|
-
MIN(first_seen_at) as first_occurrence,
|
|
42
|
-
MAX(last_seen_at) as last_occurrence,
|
|
43
|
-
COUNT(*) as unique_error_count")
|
|
44
|
-
.order("total_occurrences DESC")
|
|
45
|
-
.limit(10)
|
|
46
|
-
.map do |error|
|
|
47
|
-
first_seen = error.first_occurrence.is_a?(Time) ? error.first_occurrence : Time.parse(error.first_occurrence.to_s)
|
|
48
|
-
last_seen = error.last_occurrence.is_a?(Time) ? error.last_occurrence : Time.parse(error.last_occurrence.to_s)
|
|
53
|
+
events_by_group = Queries::EventVolume.by_group_attribute(app_scope, :id, @start_date)
|
|
54
|
+
.select { |_, count| count > HIGH_FREQUENCY_THRESHOLD }
|
|
55
|
+
return [] if events_by_group.empty?
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
groups = ErrorLog.where(id: events_by_group.keys)
|
|
58
|
+
.pluck(:id, :error_type, :first_seen_at, :last_seen_at, :occurred_at)
|
|
59
|
+
|
|
60
|
+
groups.group_by { |_, error_type, *| error_type }
|
|
61
|
+
.map do |error_type, rows|
|
|
62
|
+
# first_seen_at/last_seen_at are NULL on rows from before those
|
|
63
|
+
# columns; occurred_at is the only timestamp such a row has.
|
|
64
|
+
first_seen = rows.map { |_, _, first, _, occurred| first || occurred }.min
|
|
65
|
+
last_seen = rows.map { |_, _, _, last, occurred| last || occurred }.max
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
error_type: error_type,
|
|
69
|
+
total_occurrences: rows.sum { |id, *| events_by_group[id] },
|
|
70
|
+
first_seen: first_seen,
|
|
71
|
+
last_seen: last_seen,
|
|
72
|
+
duration_days: ((last_seen - first_seen) / 1.day).round,
|
|
73
|
+
still_active: last_seen > 24.hours.ago
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
.sort_by { |entry| [ -entry[:total_occurrences], entry[:error_type].to_s ] }
|
|
77
|
+
.first(10)
|
|
59
78
|
end
|
|
60
79
|
|
|
61
80
|
def persistent_errors
|
|
@@ -29,30 +29,48 @@ module RailsErrorDashboard
|
|
|
29
29
|
|
|
30
30
|
private
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
# Every group of the selected application, NOT cut by date. EventVolume
|
|
33
|
+
# and the row samples need this: an error first seen before the window
|
|
34
|
+
# that recurs inside it is still this window's error.
|
|
35
|
+
def app_scope
|
|
36
|
+
scope = ErrorLog.all
|
|
35
37
|
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
36
38
|
scope
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
# Groups FIRST SEEN in the window with a user -- the group-level fallback
|
|
42
|
+
# for errors whose events predate occurrence tracking (see
|
|
43
|
+
# distinct_users_by_type).
|
|
44
|
+
def base_scope
|
|
45
|
+
app_scope.where("occurred_at >= ?", @start_date).where.not(user_id: nil)
|
|
46
|
+
end
|
|
47
|
+
|
|
39
48
|
def build_entries
|
|
40
49
|
# Distinct users per error type, from OCCURRENCE rows: the group's
|
|
41
50
|
# user_id is overwritten by each new occurrence, so counting it
|
|
42
51
|
# distinct across groups reported five users hitting one error as one.
|
|
43
52
|
user_counts = distinct_users_by_type
|
|
44
53
|
|
|
45
|
-
# EVENTS per type,
|
|
46
|
-
# storm-shed
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
54
|
+
# EVENTS per type in the window, each counted where it happened --
|
|
55
|
+
# occurrence rows, storm-shed buckets and the untracked remainder.
|
|
56
|
+
# Summing the lifetime occurrence_count of groups born in the window
|
|
57
|
+
# gave an August error recurring today zero occurrences on a row that
|
|
58
|
+
# still listed its users.
|
|
59
|
+
occurrence_counts = Queries::EventVolume.by_group_attribute(app_scope, :error_type, @start_date)
|
|
50
60
|
|
|
51
61
|
total_users = effective_total_users
|
|
52
62
|
|
|
53
63
|
user_counts.map do |error_type, unique_users|
|
|
54
64
|
occurrences = occurrence_counts[error_type] || 0
|
|
55
|
-
|
|
65
|
+
# The row's message, severity and link: the most recently seen group
|
|
66
|
+
# of this type. Taken from app_scope for the same reason as the
|
|
67
|
+
# counts -- a first-seen scope has no sample for an older error, and
|
|
68
|
+
# the row rendered with no message and nothing to click.
|
|
69
|
+
# COALESCE, not a bare last_seen_at DESC: PostgreSQL sorts NULLs
|
|
70
|
+
# FIRST in descending order, so a row from before the column existed
|
|
71
|
+
# would outrank every live one there.
|
|
72
|
+
sample = app_scope.where(error_type: error_type).where.not(user_id: nil)
|
|
73
|
+
.order(Arel.sql("COALESCE(last_seen_at, occurred_at) DESC"), id: :desc).first
|
|
56
74
|
impact_pct = total_users && total_users > 0 ? (unique_users.to_f / total_users * 100).round(1) : nil
|
|
57
75
|
|
|
58
76
|
{
|
|
@@ -62,7 +80,8 @@ module RailsErrorDashboard
|
|
|
62
80
|
total_occurrences: occurrences,
|
|
63
81
|
impact_percentage: impact_pct,
|
|
64
82
|
severity: sample&.severity,
|
|
65
|
-
|
|
83
|
+
# "Last Seen" -- occurred_at is FIRST-seen and never moves.
|
|
84
|
+
last_seen: sample&.last_seen_at || sample&.occurred_at,
|
|
66
85
|
id: sample&.id
|
|
67
86
|
}
|
|
68
87
|
end.sort_by { |e| -e[:unique_users] }
|
|
@@ -56,17 +56,39 @@ module RailsErrorDashboard
|
|
|
56
56
|
I18nStore.translate(PERIODS[@period][:label_key], locale: @locale)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
# Two counting units, kept distinct on purpose (as in AnalyticsStats).
|
|
60
|
+
#
|
|
61
|
+
# EVENT figures -> Queries::EventVolume over app_scope: how much
|
|
62
|
+
# erroring happened in the period. Occurrences, the
|
|
63
|
+
# top-errors counts and the period comparison.
|
|
64
|
+
# GROUP figures -> base_scope, groups FIRST SEEN in the period: new,
|
|
65
|
+
# resolved, unresolved, critical/high, resolution rate
|
|
66
|
+
# and the critical-unresolved list.
|
|
67
|
+
#
|
|
68
|
+
# Summing the lifetime counts of groups born in the period reported an
|
|
69
|
+
# unresolved August error that fired all day today as zero occurrences,
|
|
70
|
+
# absent from Top Errors, with an empty comparison.
|
|
71
|
+
|
|
72
|
+
# Every group of the application, NOT cut by date -- what EventVolume
|
|
73
|
+
# must be handed, or it never sees an older group's recurrences.
|
|
74
|
+
def app_scope
|
|
75
|
+
scope = ErrorLog.all
|
|
61
76
|
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
62
77
|
scope
|
|
63
78
|
end
|
|
64
79
|
|
|
80
|
+
def base_scope
|
|
81
|
+
app_scope.where("occurred_at >= ?", @start_date)
|
|
82
|
+
end
|
|
83
|
+
|
|
65
84
|
def build_stats
|
|
66
85
|
scope = base_scope
|
|
67
86
|
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
# GROUP figure: errors first seen in the period, however often they
|
|
88
|
+
# fired. `occurrence_count <= 1` counted "seen once", so a brand-new
|
|
89
|
+
# error that fired twice was missing from the headline and the subject.
|
|
90
|
+
new_errors = scope.count
|
|
91
|
+
total_occurrences = Queries::EventVolume.in_window(app_scope, @start_date)
|
|
70
92
|
resolved = scope.where(resolved: true).count
|
|
71
93
|
unresolved = scope.where(resolved: false).count
|
|
72
94
|
# Severity is computed from error_type via SeverityClassifier (not a DB column).
|
|
@@ -91,15 +113,21 @@ module RailsErrorDashboard
|
|
|
91
113
|
{ new_errors: 0, total_occurrences: 0, resolved: 0, unresolved: 0, critical_high: 0, resolution_rate: 0 }
|
|
92
114
|
end
|
|
93
115
|
|
|
116
|
+
# Unresolved error types ranked by their events in the period. This used
|
|
117
|
+
# to count GROUPS per type (a type with five groups scored 5 however
|
|
118
|
+
# often each fired), and only groups born in the period.
|
|
94
119
|
def top_errors
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
.
|
|
99
|
-
.
|
|
100
|
-
.
|
|
120
|
+
unresolved = app_scope.where(resolved: false)
|
|
121
|
+
|
|
122
|
+
Queries::EventVolume.by_group_attribute(unresolved, :error_type, @start_date)
|
|
123
|
+
.select { |_, count| count.positive? }
|
|
124
|
+
.sort_by { |error_type, count| [ -count, error_type.to_s ] }
|
|
125
|
+
.first(5)
|
|
101
126
|
.map do |error_type, count|
|
|
102
|
-
|
|
127
|
+
# Most recently seen first; COALESCE because PostgreSQL sorts NULLs
|
|
128
|
+
# first in descending order.
|
|
129
|
+
sample = unresolved.where(error_type: error_type)
|
|
130
|
+
.order(Arel.sql("COALESCE(last_seen_at, occurred_at) DESC"), id: :desc).first
|
|
103
131
|
{
|
|
104
132
|
error_type: error_type,
|
|
105
133
|
message: sample&.message.to_s.truncate(100),
|
|
@@ -137,10 +165,11 @@ module RailsErrorDashboard
|
|
|
137
165
|
previous_start = (@days * 2).days.ago
|
|
138
166
|
previous_end = @start_date
|
|
139
167
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
168
|
+
# Events in each period, counted where they happened. Counting groups
|
|
169
|
+
# born in each period measured how many NEW errors appeared, not
|
|
170
|
+
# whether the application is erroring more or less.
|
|
171
|
+
current_count = Queries::EventVolume.in_window(app_scope, @start_date)
|
|
172
|
+
previous_count = Queries::EventVolume.in_window(app_scope, previous_start, previous_end)
|
|
144
173
|
|
|
145
174
|
delta = current_count - previous_count
|
|
146
175
|
percentage = previous_count > 0 ? ((delta.to_f / previous_count) * 100).round(1) : nil
|
|
@@ -171,14 +171,19 @@ namespace :error_dashboard do
|
|
|
171
171
|
if config.authenticate_with
|
|
172
172
|
puts "OK (custom authentication)"
|
|
173
173
|
checks_passed += 1
|
|
174
|
-
elsif
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
elsif (problem = config.credentials_problem)
|
|
175
|
+
# Same rule and environment allowlist as the boot check in validate!
|
|
176
|
+
label = problem == :blank ? "blank credentials" : "default credentials"
|
|
177
|
+
if config.refuse_default_credentials?
|
|
178
|
+
puts "WARNING - #{label} outside development and test!"
|
|
177
179
|
checks_failed += 1
|
|
178
180
|
else
|
|
179
|
-
puts "OK (
|
|
181
|
+
puts "OK (#{label} - change before production)"
|
|
180
182
|
warnings += 1
|
|
181
183
|
end
|
|
184
|
+
elsif config.dashboard_password == RailsErrorDashboard::Configuration::DEFAULT_DASHBOARD_PASSWORD
|
|
185
|
+
puts "OK (the published default password, set explicitly by ERROR_DASHBOARD_PASSWORD)"
|
|
186
|
+
warnings += 1
|
|
182
187
|
else
|
|
183
188
|
puts "OK (custom credentials)"
|
|
184
189
|
checks_passed += 1
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -599,7 +599,7 @@ metadata:
|
|
|
599
599
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
600
600
|
post_install_message: |
|
|
601
601
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
602
|
-
RED (Rails Error Dashboard) v0.14.
|
|
602
|
+
RED (Rails Error Dashboard) v0.14.2
|
|
603
603
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
604
604
|
|
|
605
605
|
First install:
|