rails_pulse 0.3.3 → 0.4.0.pre.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/CHANGELOG.md +299 -0
- data/README.md +44 -3
- data/app/assets/stylesheets/rails_pulse/application.css +21 -32
- data/app/assets/stylesheets/rails_pulse/components/backtrace.css +238 -0
- data/app/assets/stylesheets/rails_pulse/components/badge.css +32 -0
- data/app/assets/stylesheets/rails_pulse/components/button.css +2 -36
- data/app/assets/stylesheets/rails_pulse/components/card.css +8 -11
- data/app/assets/stylesheets/rails_pulse/components/chart.css +16 -3
- data/app/assets/stylesheets/rails_pulse/components/csp_safe_positioning.css +5 -39
- data/app/assets/stylesheets/rails_pulse/components/dashboard.css +0 -52
- data/app/assets/stylesheets/rails_pulse/components/deployments.css +39 -0
- data/app/assets/stylesheets/rails_pulse/components/dialog.css +0 -5
- data/app/assets/stylesheets/rails_pulse/components/input.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/layouts.css +0 -20
- data/app/assets/stylesheets/rails_pulse/components/menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/sidebar_menu.css +0 -16
- data/app/assets/stylesheets/rails_pulse/components/storage.css +187 -0
- data/app/assets/stylesheets/rails_pulse/components/tags.css +0 -14
- data/app/assets/stylesheets/rails_pulse/components/time_range_selector.css +5 -5
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +0 -17
- data/app/controllers/concerns/chart_table_concern.rb +15 -3
- data/app/controllers/concerns/ransack_params_concern.rb +18 -0
- data/app/controllers/concerns/response_range_concern.rb +1 -1
- data/app/controllers/concerns/time_range_concern.rb +28 -14
- data/app/controllers/rails_pulse/application_controller.rb +104 -31
- data/app/controllers/rails_pulse/dashboard_controller.rb +9 -0
- data/app/controllers/rails_pulse/deployments_controller.rb +26 -5
- data/app/controllers/rails_pulse/exception_occurrences_controller.rb +19 -0
- data/app/controllers/rails_pulse/exceptions_controller.rb +119 -0
- data/app/controllers/rails_pulse/job_runs_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +1 -1
- data/app/controllers/rails_pulse/routes_controller.rb +7 -1
- data/app/controllers/rails_pulse/storage_controller.rb +7 -0
- data/app/controllers/rails_pulse/tags_controller.rb +2 -2
- data/app/helpers/rails_pulse/application_helper.rb +1 -0
- data/app/helpers/rails_pulse/backtrace_helper.rb +85 -0
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +4 -1
- data/app/helpers/rails_pulse/chart_helper.rb +0 -3
- data/app/helpers/rails_pulse/route_helper.rb +8 -16
- data/app/javascript/rails_pulse/application.js +29 -8
- data/app/javascript/rails_pulse/controllers/clipboard_controller.js +58 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +16 -10
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +6 -2
- data/app/javascript/rails_pulse/controllers/popover_controller.js +4 -0
- data/app/models/concerns/rails_pulse/taggable.rb +11 -6
- data/app/models/rails_pulse/cards/base.rb +53 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +52 -0
- data/app/models/rails_pulse/dashboard/needs_attention.rb +73 -1
- data/app/models/rails_pulse/dashboard/storage_status.rb +490 -0
- data/app/models/rails_pulse/deployment.rb +47 -1
- data/app/models/rails_pulse/exception_group.rb +37 -0
- data/app/models/rails_pulse/exception_occurrence.rb +27 -0
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +116 -0
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +49 -0
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +62 -0
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +56 -0
- data/app/models/rails_pulse/exceptions/raw_data.rb +216 -0
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -2
- data/app/models/rails_pulse/operation.rb +13 -4
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +8 -3
- data/app/models/rails_pulse/request.rb +5 -1
- data/app/models/rails_pulse/route.rb +71 -21
- data/app/models/rails_pulse/routes/cards/error_rates.rb +6 -3
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +8 -3
- data/app/models/rails_pulse/routes/tables/index.rb +8 -6
- data/app/models/rails_pulse/summary.rb +21 -10
- data/app/models/rails_pulse/tables/base.rb +4 -2
- data/app/services/rails_pulse/analysis/explain_plan_analyzer.rb +47 -12
- data/app/services/rails_pulse/exception_capture_service.rb +228 -0
- data/app/services/rails_pulse/exception_message_sanitizer.rb +152 -0
- data/app/services/rails_pulse/operations/change_point.rb +144 -0
- data/app/services/rails_pulse/operations/compare.rb +92 -0
- data/app/services/rails_pulse/operations/comparison.rb +126 -0
- data/app/services/rails_pulse/operations/metric.rb +63 -0
- data/app/services/rails_pulse/operations/series.rb +84 -0
- data/app/services/rails_pulse/operations/subject.rb +77 -0
- data/app/services/rails_pulse/route_controller_action_backfiller.rb +110 -0
- data/app/services/rails_pulse/route_merger.rb +99 -0
- data/app/services/rails_pulse/route_migrator.rb +139 -0
- data/app/services/rails_pulse/route_path_normalizer.rb +79 -0
- data/app/services/rails_pulse/route_recognizer.rb +47 -0
- data/app/services/rails_pulse/summary_service.rb +45 -0
- data/app/services/rails_pulse/tag_filter_service.rb +5 -3
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/_onboarding_banner.html.erb +8 -0
- data/app/views/layouts/rails_pulse/_setup_screen.html.erb +5 -5
- data/app/views/layouts/rails_pulse/application.html.erb +4 -4
- data/app/views/rails_pulse/components/_empty_state.html.erb +1 -1
- data/app/views/rails_pulse/components/_panel.html.erb +16 -6
- data/app/views/rails_pulse/components/_raw_data.html.erb +34 -0
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -8
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +64 -0
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +21 -10
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +67 -0
- data/app/views/rails_pulse/dashboard/index.html.erb +22 -1
- data/app/views/rails_pulse/deployments/_status_badge.html.erb +5 -0
- data/app/views/rails_pulse/deployments/_table.html.erb +29 -0
- data/app/views/rails_pulse/deployments/index.html.erb +15 -0
- data/app/views/rails_pulse/deployments/show.html.erb +62 -0
- data/app/views/rails_pulse/exception_occurrences/show.html.erb +45 -0
- data/app/views/rails_pulse/exceptions/_backtrace.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/_metric_cards.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_occurrences_table.html.erb +23 -0
- data/app/views/rails_pulse/exceptions/_status_badge.html.erb +6 -0
- data/app/views/rails_pulse/exceptions/_table.html.erb +33 -0
- data/app/views/rails_pulse/exceptions/index.html.erb +66 -0
- data/app/views/rails_pulse/exceptions/show.html.erb +52 -0
- data/app/views/rails_pulse/queries/_raw_data.html.erb +1 -23
- data/app/views/rails_pulse/requests/_table.html.erb +1 -1
- data/app/views/rails_pulse/routes/_requests_table.html.erb +5 -0
- data/app/views/rails_pulse/routes/_table.html.erb +22 -8
- data/app/views/rails_pulse/routes/index.html.erb +5 -1
- data/app/views/rails_pulse/routes/show.html.erb +5 -0
- data/app/views/rails_pulse/storage/show.html.erb +123 -0
- data/config/routes.rb +13 -4
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +50 -0
- data/db/rails_pulse_migrate/20260507000001_add_actual_query_to_operations.rb +10 -5
- data/db/rails_pulse_migrate/20260610000001_add_controller_action_to_rails_pulse_routes.rb +8 -0
- data/db/rails_pulse_migrate/20260610000002_change_rails_pulse_routes_to_multi_verb_model.rb +185 -0
- data/db/rails_pulse_migrate/20260610000003_add_null_action_unique_index_to_routes.rb +32 -0
- data/db/rails_pulse_migrate/20260823000001_add_location_to_exception_groups.rb +12 -0
- data/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/base_methods.rb +2 -0
- data/lib/generators/rails_pulse/install_generator.rb +5 -0
- data/lib/generators/rails_pulse/schema_parser.rb +22 -2
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +54 -6
- data/lib/generators/rails_pulse/templates/migrations/upgrade_rails_pulse_tables.rb +1 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +157 -16
- data/lib/generators/rails_pulse/upgrade_generator.rb +166 -38
- data/lib/rails_pulse/cleanup_service.rb +110 -15
- data/lib/rails_pulse/configuration.rb +125 -6
- data/lib/rails_pulse/engine.rb +32 -17
- data/lib/rails_pulse/installers/config_updater.rb +202 -0
- data/lib/rails_pulse/job_run_collector.rb +43 -16
- data/lib/rails_pulse/middleware/asset_server.rb +11 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -4
- data/lib/rails_pulse/packaged_assets.rb +147 -0
- data/lib/rails_pulse/route_indexes.rb +66 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +36 -0
- data/lib/rails_pulse/tracker.rb +29 -10
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +1 -0
- data/lib/rails_pulse_server.ru +16 -47
- data/lib/tasks/rails_pulse.rake +83 -4
- data/lib/tasks/rails_pulse_assets.rake +15 -0
- data/lib/tasks/rails_pulse_tasks.rake +29 -0
- data/public/rails-pulse-assets/rails-pulse-icons.js +6 -2
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +18 -117
- metadata +78 -37
- data/Rakefile +0 -554
- data/app/javascript/rails_pulse/controllers/form_controller.js +0 -39
- data/app/javascript/rails_pulse/controllers/period_selector_controller.js +0 -19
- data/app/javascript/rails_pulse/controllers/timezone_controller.js +0 -89
- data/app/javascript/rails_pulse/theme.js +0 -416
- data/config/brakeman.ignore +0 -190
- data/config/brakeman.yml +0 -68
- data/config/initializers/rails_pulse.rb +0 -287
- data/public/rails-pulse-assets/csp-test.js +0 -120
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +0 -13
- data/public/rails-pulse-assets/rails-pulse.css.map +0 -1
- data/public/rails-pulse-assets/rails-pulse.js.map +0 -7
- data/vendor/assets/javascripts/rails-pulse-icons.js +0 -93
- data/vendor/assets/javascripts/rails-pulse-icons.js.map +0 -13
- data/vendor/assets/javascripts/rails-pulse.js +0 -157
- data/vendor/assets/javascripts/rails-pulse.js.map +0 -7
- data/vendor/assets/stylesheets/rails-pulse.css +0 -1
- data/vendor/assets/stylesheets/rails-pulse.css.map +0 -1
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Dashboard
|
|
3
|
+
class StorageStatus
|
|
4
|
+
TABLE_CATALOG = [
|
|
5
|
+
{
|
|
6
|
+
name: :rails_pulse_operations,
|
|
7
|
+
model: "RailsPulse::Operation",
|
|
8
|
+
label: "Operations",
|
|
9
|
+
description: "SQL, view, cache, and other work captured inside requests and jobs",
|
|
10
|
+
time_column: :occurred_at
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: :rails_pulse_requests,
|
|
14
|
+
model: "RailsPulse::Request",
|
|
15
|
+
label: "Requests",
|
|
16
|
+
description: "Individual HTTP requests",
|
|
17
|
+
time_column: :occurred_at
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: :rails_pulse_job_runs,
|
|
21
|
+
model: "RailsPulse::JobRun",
|
|
22
|
+
label: "Job runs",
|
|
23
|
+
description: "Individual background job executions",
|
|
24
|
+
time_column: :occurred_at
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: :rails_pulse_exception_occurrences,
|
|
28
|
+
model: "RailsPulse::ExceptionOccurrence",
|
|
29
|
+
label: "Exception occurrences",
|
|
30
|
+
description: "Stored exception instances (groups keep a lifetime count after cleanup)",
|
|
31
|
+
time_column: :occurred_at
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: :rails_pulse_summaries,
|
|
35
|
+
model: "RailsPulse::Summary",
|
|
36
|
+
label: "Summaries",
|
|
37
|
+
description: "Hourly and daily aggregates used by charts. Hourly rows older than 2 days are cleaned up",
|
|
38
|
+
time_column: :period_start
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: :rails_pulse_queries,
|
|
42
|
+
model: "RailsPulse::Query",
|
|
43
|
+
label: "Queries",
|
|
44
|
+
description: "Normalized SQL query fingerprints",
|
|
45
|
+
time_column: :created_at
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: :rails_pulse_routes,
|
|
49
|
+
model: "RailsPulse::Route",
|
|
50
|
+
label: "Routes",
|
|
51
|
+
description: "Unique request paths",
|
|
52
|
+
time_column: :created_at
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: :rails_pulse_jobs,
|
|
56
|
+
model: "RailsPulse::Job",
|
|
57
|
+
label: "Jobs",
|
|
58
|
+
description: "Unique background job class names",
|
|
59
|
+
time_column: :created_at
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: :rails_pulse_exception_groups,
|
|
63
|
+
model: "RailsPulse::ExceptionGroup",
|
|
64
|
+
label: "Exception groups",
|
|
65
|
+
description: "Distinct exception sites. Preserved groups are never deleted",
|
|
66
|
+
time_column: :last_seen_at
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: :rails_pulse_deployments,
|
|
70
|
+
model: "RailsPulse::Deployment",
|
|
71
|
+
label: "Deployments",
|
|
72
|
+
description: "Deploy markers shown on charts",
|
|
73
|
+
time_column: :started_at
|
|
74
|
+
}
|
|
75
|
+
].freeze
|
|
76
|
+
|
|
77
|
+
# One-off screenshot fixture. Set SCREENSHOT to false (or delete this block) when done.
|
|
78
|
+
SCREENSHOT = !Rails.env.test?
|
|
79
|
+
SCREENSHOT_STATE = :warning # :healthy, :warning, :critical
|
|
80
|
+
SCREENSHOT_TABLES = {
|
|
81
|
+
healthy: {
|
|
82
|
+
rails_pulse_operations: { count: 42_180, limit: 100_000, recent: 3_140, bytes: 100_663_296, days_old: 14 },
|
|
83
|
+
rails_pulse_requests: { count: 18_420, limit: 50_000, recent: 1_280, bytes: 42_991_616, days_old: 14 },
|
|
84
|
+
rails_pulse_job_runs: { count: 12_104, limit: 50_000, recent: 890, bytes: 29_360_128, days_old: 14 },
|
|
85
|
+
rails_pulse_exception_occurrences: { count: 1_842, limit: 50_000, recent: 22, bytes: 11_534_336, days_old: 14 },
|
|
86
|
+
rails_pulse_summaries: { count: 1_128, limit: nil, recent: 48, bytes: 4_403_200, days_old: 14 },
|
|
87
|
+
rails_pulse_queries: { count: 486, limit: 10_000, recent: 6, bytes: 2_202_010, days_old: 62 },
|
|
88
|
+
rails_pulse_routes: { count: 214, limit: 1_000, recent: 1, bytes: 421_888, days_old: 62 },
|
|
89
|
+
rails_pulse_jobs: { count: 38, limit: 1_000, recent: 0, bytes: 98_304, days_old: 62 },
|
|
90
|
+
rails_pulse_exception_groups: { count: 64, limit: 10_000, recent: 2, bytes: 184_320, days_old: 62 },
|
|
91
|
+
rails_pulse_deployments: { count: 12, limit: nil, recent: 0, bytes: 49_152, days_old: 48 }
|
|
92
|
+
},
|
|
93
|
+
warning: {
|
|
94
|
+
rails_pulse_operations: { count: 78_410, limit: 100_000, recent: 6_820, bytes: 176_160_768, days_old: 14 },
|
|
95
|
+
rails_pulse_requests: { count: 36_210, limit: 50_000, recent: 2_410, bytes: 81_788_928, days_old: 14 },
|
|
96
|
+
rails_pulse_job_runs: { count: 31_002, limit: 50_000, recent: 1_940, bytes: 54_525_952, days_old: 14 },
|
|
97
|
+
rails_pulse_exception_occurrences: { count: 4_118, limit: 50_000, recent: 40, bytes: 18_874_368, days_old: 14 },
|
|
98
|
+
rails_pulse_summaries: { count: 1_240, limit: nil, recent: 48, bytes: 4_612_096, days_old: 14 },
|
|
99
|
+
rails_pulse_queries: { count: 812, limit: 10_000, recent: 12, bytes: 3_250_176, days_old: 90 },
|
|
100
|
+
rails_pulse_routes: { count: 268, limit: 1_000, recent: 2, bytes: 491_520, days_old: 90 },
|
|
101
|
+
rails_pulse_jobs: { count: 41, limit: 1_000, recent: 0, bytes: 106_496, days_old: 90 },
|
|
102
|
+
rails_pulse_exception_groups: { count: 91, limit: 10_000, recent: 3, bytes: 225_280, days_old: 90 },
|
|
103
|
+
rails_pulse_deployments: { count: 18, limit: nil, recent: 1, bytes: 65_536, days_old: 90 }
|
|
104
|
+
},
|
|
105
|
+
critical: {
|
|
106
|
+
rails_pulse_operations: { count: 104_280, limit: 100_000, recent: 8_110, bytes: 224_395_264, days_old: 18 },
|
|
107
|
+
rails_pulse_requests: { count: 48_902, limit: 50_000, recent: 3_040, bytes: 106_954_752, days_old: 18 },
|
|
108
|
+
rails_pulse_job_runs: { count: 46_118, limit: 50_000, recent: 2_280, bytes: 84_934_656, days_old: 18 },
|
|
109
|
+
rails_pulse_exception_occurrences: { count: 8_640, limit: 50_000, recent: 90, bytes: 25_165_824, days_old: 18 },
|
|
110
|
+
rails_pulse_summaries: { count: 1_410, limit: nil, recent: 24, bytes: 5_033_984, days_old: 18 },
|
|
111
|
+
rails_pulse_queries: { count: 1_204, limit: 10_000, recent: 18, bytes: 4_300_800, days_old: 120 },
|
|
112
|
+
rails_pulse_routes: { count: 312, limit: 1_000, recent: 4, bytes: 552_960, days_old: 120 },
|
|
113
|
+
rails_pulse_jobs: { count: 44, limit: 1_000, recent: 0, bytes: 114_688, days_old: 120 },
|
|
114
|
+
rails_pulse_exception_groups: { count: 128, limit: 10_000, recent: 5, bytes: 286_720, days_old: 120 },
|
|
115
|
+
rails_pulse_deployments: { count: 22, limit: nil, recent: 1, bytes: 73_728, days_old: 120 }
|
|
116
|
+
}
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
def initialize
|
|
120
|
+
@config = RailsPulse.configuration
|
|
121
|
+
@pressure = StoragePressure.new
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def tables
|
|
125
|
+
@tables ||= TABLE_CATALOG.filter_map { |definition| build_table(definition) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pressure_items
|
|
129
|
+
@pressure.pressure_items
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def cleanup
|
|
133
|
+
@cleanup ||= {
|
|
134
|
+
enabled: @config.archiving_enabled,
|
|
135
|
+
retention_period: @config.full_retention_period,
|
|
136
|
+
retention_label: humanize_duration(@config.full_retention_period),
|
|
137
|
+
last_summary_at: last_hourly_summary_end,
|
|
138
|
+
health: cleanup_health,
|
|
139
|
+
health_label: cleanup_health_label
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def database
|
|
144
|
+
@database ||= {
|
|
145
|
+
adapter: adapter_label,
|
|
146
|
+
separate: RailsPulse.connects_to.present?,
|
|
147
|
+
setup_label: RailsPulse.connects_to.present? ? "Separate database" : "Shared with host app",
|
|
148
|
+
database_name: database_name,
|
|
149
|
+
file_size: sqlite_file_size,
|
|
150
|
+
table_bytes: tables_with_size? ? tables.sum { |table| table[:bytes].to_i } : nil,
|
|
151
|
+
size_available: size_available?,
|
|
152
|
+
size_note: size_note
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def dashboard_tables
|
|
157
|
+
capped = tables.select { |table| table[:limit] }
|
|
158
|
+
return tables.first(4) if capped.empty?
|
|
159
|
+
|
|
160
|
+
capped.sort_by { |table| -table[:percent].to_f }.first(4)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def overview
|
|
164
|
+
hottest = hottest_capped_table
|
|
165
|
+
{
|
|
166
|
+
hottest_label: hottest ? hottest[:label] : "—",
|
|
167
|
+
hottest_percent: hottest ? hottest[:percent] : nil,
|
|
168
|
+
total_records: tables.sum { |table| table[:count] },
|
|
169
|
+
display_bytes: display_bytes,
|
|
170
|
+
cleanup_health: cleanup[:health],
|
|
171
|
+
cleanup_label: cleanup[:health_label]
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def connection
|
|
178
|
+
RailsPulse::ApplicationRecord.connection
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def screenshot_row(table_name)
|
|
182
|
+
return unless SCREENSHOT
|
|
183
|
+
|
|
184
|
+
row = SCREENSHOT_TABLES.dig(SCREENSHOT_STATE, table_name)
|
|
185
|
+
return unless row
|
|
186
|
+
|
|
187
|
+
oldest_at = row[:days_old].days.ago
|
|
188
|
+
{
|
|
189
|
+
count: row[:count],
|
|
190
|
+
limit: row[:limit],
|
|
191
|
+
recent_count: row[:recent],
|
|
192
|
+
bytes: row[:bytes],
|
|
193
|
+
oldest_at: oldest_at,
|
|
194
|
+
newest_at: 8.minutes.ago
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_table(definition)
|
|
199
|
+
screenshot = screenshot_row(definition[:name])
|
|
200
|
+
unless screenshot
|
|
201
|
+
model = definition[:model].safe_constantize
|
|
202
|
+
return unless model
|
|
203
|
+
return unless connection.table_exists?(definition[:name])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
stats = screenshot || fetch_counts(definition[:model].constantize, definition)
|
|
207
|
+
limit = screenshot ? screenshot[:limit] : @config.max_table_records&.[](definition[:name])
|
|
208
|
+
count = stats[:count]
|
|
209
|
+
percent = limit.to_i.positive? ? ((count.to_f / limit) * 100).round(1) : nil
|
|
210
|
+
severity = fill_severity(percent, count, limit)
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
name: definition[:name],
|
|
214
|
+
label: definition[:label],
|
|
215
|
+
description: definition[:description],
|
|
216
|
+
count: count,
|
|
217
|
+
limit: limit,
|
|
218
|
+
percent: percent,
|
|
219
|
+
percent_label: percent_label(percent),
|
|
220
|
+
bar_width: bar_width(percent),
|
|
221
|
+
severity: severity,
|
|
222
|
+
oldest_at: stats[:oldest_at],
|
|
223
|
+
newest_at: stats[:newest_at],
|
|
224
|
+
recent_count: stats[:recent_count],
|
|
225
|
+
runway_label: runway_label(count, limit, stats[:recent_count]),
|
|
226
|
+
bytes: screenshot ? screenshot[:bytes] : table_bytes(definition[:name]),
|
|
227
|
+
history_label: history_label(stats[:oldest_at], stats[:newest_at])
|
|
228
|
+
}
|
|
229
|
+
rescue => error
|
|
230
|
+
RailsPulse.logger.warn("StorageStatus failed for #{definition[:name]}: #{error.message}")
|
|
231
|
+
{
|
|
232
|
+
name: definition[:name],
|
|
233
|
+
label: definition[:label],
|
|
234
|
+
description: definition[:description],
|
|
235
|
+
count: 0,
|
|
236
|
+
limit: @config.max_table_records&.[](definition[:name]),
|
|
237
|
+
percent: nil,
|
|
238
|
+
percent_label: nil,
|
|
239
|
+
bar_width: 0,
|
|
240
|
+
severity: :error,
|
|
241
|
+
oldest_at: nil,
|
|
242
|
+
newest_at: nil,
|
|
243
|
+
recent_count: 0,
|
|
244
|
+
runway_label: "Error loading",
|
|
245
|
+
bytes: nil,
|
|
246
|
+
history_label: error.message
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def fetch_counts(model, definition)
|
|
251
|
+
quoted_table = connection.quote_table_name(definition[:name])
|
|
252
|
+
time_column = definition[:time_column]
|
|
253
|
+
|
|
254
|
+
unless time_column && model.column_names.include?(time_column.to_s)
|
|
255
|
+
count = connection.select_value("SELECT COUNT(*) FROM #{quoted_table}").to_i
|
|
256
|
+
return { count: count, oldest_at: nil, newest_at: nil, recent_count: 0 }
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
quoted_column = connection.quote_column_name(time_column)
|
|
260
|
+
row = connection.select_one(
|
|
261
|
+
model.sanitize_sql_array([ <<~SQL, 1.day.ago ])
|
|
262
|
+
SELECT COUNT(*) AS record_count,
|
|
263
|
+
MIN(#{quoted_column}) AS oldest_at,
|
|
264
|
+
MAX(#{quoted_column}) AS newest_at,
|
|
265
|
+
SUM(CASE WHEN #{quoted_column} >= ? THEN 1 ELSE 0 END) AS recent_count
|
|
266
|
+
FROM #{quoted_table}
|
|
267
|
+
SQL
|
|
268
|
+
) || {}
|
|
269
|
+
|
|
270
|
+
{
|
|
271
|
+
count: row_value(row, "record_count").to_i,
|
|
272
|
+
oldest_at: parse_time(row_value(row, "oldest_at")),
|
|
273
|
+
newest_at: parse_time(row_value(row, "newest_at")),
|
|
274
|
+
recent_count: row_value(row, "recent_count").to_i
|
|
275
|
+
}
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def fill_severity(percent, count, limit)
|
|
279
|
+
return :uncapped if limit.nil?
|
|
280
|
+
return :critical if count > limit || percent.to_f >= 90
|
|
281
|
+
return :warning if percent.to_f >= 70
|
|
282
|
+
|
|
283
|
+
:healthy
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def percent_label(percent)
|
|
287
|
+
return if percent.nil?
|
|
288
|
+
return "< 0.1%" if percent < 0.1
|
|
289
|
+
|
|
290
|
+
"#{percent}%"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def bar_width(percent)
|
|
294
|
+
return 0 if percent.nil?
|
|
295
|
+
|
|
296
|
+
[ [ percent, 0 ].max, 100 ].min
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def runway_label(count, limit, recent_count)
|
|
300
|
+
return "No cap" if limit.nil?
|
|
301
|
+
return "Over cap" if count >= limit
|
|
302
|
+
return "No recent growth" if recent_count.to_i.zero?
|
|
303
|
+
|
|
304
|
+
days = (limit - count).to_f / recent_count
|
|
305
|
+
if days < 1
|
|
306
|
+
"< 1 day to cap"
|
|
307
|
+
elsif days < 90
|
|
308
|
+
"~#{days.round} #{'day'.pluralize(days.round)} to cap"
|
|
309
|
+
else
|
|
310
|
+
"Well under cap"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def history_label(oldest_at, newest_at)
|
|
315
|
+
return "No rows" unless oldest_at && newest_at
|
|
316
|
+
|
|
317
|
+
span_days = ((newest_at - oldest_at) / 1.day).round
|
|
318
|
+
span = if span_days < 1
|
|
319
|
+
"< 1 day of history"
|
|
320
|
+
else
|
|
321
|
+
"#{span_days} #{'day'.pluralize(span_days)} of history"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
"#{span} · oldest #{time_ago(oldest_at)}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def time_ago(time)
|
|
328
|
+
seconds = [ Time.current - time, 0 ].max
|
|
329
|
+
if seconds < 60
|
|
330
|
+
"just now"
|
|
331
|
+
elsif seconds < 3_600
|
|
332
|
+
"#{(seconds / 60).to_i}m ago"
|
|
333
|
+
elsif seconds < 86_400
|
|
334
|
+
"#{(seconds / 3_600).to_i}h ago"
|
|
335
|
+
else
|
|
336
|
+
days = (seconds / 86_400).to_i
|
|
337
|
+
"#{days}d ago"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def parse_time(value)
|
|
342
|
+
return if value.blank?
|
|
343
|
+
return value.in_time_zone if value.respond_to?(:in_time_zone) && !value.is_a?(String)
|
|
344
|
+
|
|
345
|
+
(Time.zone || Time).parse(value.to_s)
|
|
346
|
+
rescue ArgumentError, TypeError
|
|
347
|
+
nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def row_value(row, key)
|
|
351
|
+
row[key] || row[key.to_s] || row[key.to_s.upcase] || row[key.to_sym]
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def table_bytes(table_name)
|
|
355
|
+
adapter = connection.adapter_name.downcase
|
|
356
|
+
|
|
357
|
+
if adapter.include?("postgres")
|
|
358
|
+
connection.select_value(
|
|
359
|
+
"SELECT pg_total_relation_size(#{connection.quote(table_name.to_s)})"
|
|
360
|
+
).to_i
|
|
361
|
+
elsif adapter.include?("mysql")
|
|
362
|
+
connection.select_value(
|
|
363
|
+
RailsPulse::ApplicationRecord.sanitize_sql_array([ <<~SQL, table_name.to_s ])
|
|
364
|
+
SELECT DATA_LENGTH + INDEX_LENGTH
|
|
365
|
+
FROM information_schema.TABLES
|
|
366
|
+
WHERE TABLE_SCHEMA = DATABASE()
|
|
367
|
+
AND TABLE_NAME = ?
|
|
368
|
+
SQL
|
|
369
|
+
)&.to_i
|
|
370
|
+
elsif adapter.include?("sqlite")
|
|
371
|
+
sqlite_table_bytes(table_name)
|
|
372
|
+
end
|
|
373
|
+
rescue ActiveRecord::StatementInvalid
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def sqlite_table_bytes(table_name)
|
|
378
|
+
connection.select_value(
|
|
379
|
+
RailsPulse::ApplicationRecord.sanitize_sql_array([
|
|
380
|
+
"SELECT SUM(pgsize) FROM dbstat WHERE name = ?",
|
|
381
|
+
table_name.to_s
|
|
382
|
+
])
|
|
383
|
+
)&.to_i
|
|
384
|
+
rescue ActiveRecord::StatementInvalid
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def sqlite_file_size
|
|
389
|
+
return unless connection.adapter_name.downcase.include?("sqlite")
|
|
390
|
+
return unless RailsPulse.connects_to.present?
|
|
391
|
+
|
|
392
|
+
path = database_name
|
|
393
|
+
return unless path.present? && File.exist?(path)
|
|
394
|
+
|
|
395
|
+
File.size(path)
|
|
396
|
+
rescue StandardError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def database_name
|
|
401
|
+
config = RailsPulse::ApplicationRecord.connection_db_config
|
|
402
|
+
config.respond_to?(:database) ? config.database : config.configuration_hash[:database]
|
|
403
|
+
rescue StandardError
|
|
404
|
+
nil
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def adapter_label
|
|
408
|
+
name = connection.adapter_name
|
|
409
|
+
case name.downcase
|
|
410
|
+
when /postgres/ then "PostgreSQL"
|
|
411
|
+
when /mysql/ then "MySQL"
|
|
412
|
+
when /sqlite/ then "SQLite"
|
|
413
|
+
else name
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def tables_with_size?
|
|
418
|
+
tables.any? { |table| table[:bytes].present? }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def size_available?
|
|
422
|
+
tables_with_size? || sqlite_file_size.present?
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def display_bytes
|
|
426
|
+
return sqlite_file_size if sqlite_file_size
|
|
427
|
+
return tables.sum { |table| table[:bytes].to_i } if tables_with_size?
|
|
428
|
+
|
|
429
|
+
nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def size_note
|
|
433
|
+
adapter = connection.adapter_name.downcase
|
|
434
|
+
if adapter.include?("sqlite") && RailsPulse.connects_to.blank?
|
|
435
|
+
"SQLite is sharing the host database, so file size would include the rest of the app. Per-table size is shown when SQLite dbstat is available."
|
|
436
|
+
elsif adapter.include?("sqlite") && RailsPulse.connects_to.present?
|
|
437
|
+
"File size of the dedicated Rails Pulse SQLite database."
|
|
438
|
+
elsif adapter.include?("postgres")
|
|
439
|
+
"PostgreSQL relation size including indexes (pg_total_relation_size)."
|
|
440
|
+
elsif adapter.include?("mysql")
|
|
441
|
+
"MySQL data + index length from information_schema."
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def hottest_capped_table
|
|
446
|
+
tables.select { |table| table[:percent] }.max_by { |table| table[:percent] }
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def last_hourly_summary_end
|
|
450
|
+
RailsPulse::Summary
|
|
451
|
+
.where(summarizable_type: "RailsPulse::Request", summarizable_id: 0, period_type: "hour")
|
|
452
|
+
.maximum(:period_end)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def cleanup_health
|
|
456
|
+
items = pressure_items
|
|
457
|
+
return :critical if items.any? { |item| item[:severity] == :critical }
|
|
458
|
+
return :warning if items.any? { |item| item[:severity] == :warning }
|
|
459
|
+
return :disabled unless @config.archiving_enabled
|
|
460
|
+
|
|
461
|
+
:healthy
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def cleanup_health_label
|
|
465
|
+
case cleanup_health
|
|
466
|
+
when :critical then "Cleanup blocked"
|
|
467
|
+
when :warning then "Needs attention"
|
|
468
|
+
when :disabled then "Cleanup off"
|
|
469
|
+
else "Healthy"
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def humanize_duration(duration)
|
|
474
|
+
return "Not set" unless duration
|
|
475
|
+
|
|
476
|
+
total_seconds = duration.to_i
|
|
477
|
+
if total_seconds >= 86_400
|
|
478
|
+
days = total_seconds / 86_400
|
|
479
|
+
"#{days} #{'day'.pluralize(days)}"
|
|
480
|
+
elsif total_seconds >= 3_600
|
|
481
|
+
hours = total_seconds / 3_600
|
|
482
|
+
"#{hours} #{'hour'.pluralize(hours)}"
|
|
483
|
+
else
|
|
484
|
+
minutes = total_seconds / 60
|
|
485
|
+
"#{minutes} #{'minute'.pluralize(minutes)}"
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
@@ -2,8 +2,18 @@ module RailsPulse
|
|
|
2
2
|
class Deployment < RailsPulse::ApplicationRecord
|
|
3
3
|
self.table_name = "rails_pulse_deployments"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# The create endpoint is reachable with a CI token, so every field a
|
|
6
|
+
# caller controls is bounded: a leaked token must not be able to fill the
|
|
7
|
+
# table or push a marker into the future (which would stamp every later
|
|
8
|
+
# exception with a revision that has not shipped).
|
|
9
|
+
MAX_REVISION_LENGTH = 255
|
|
10
|
+
MAX_METADATA_BYTES = 4.kilobytes
|
|
11
|
+
MAX_CLOCK_SKEW = 1.hour
|
|
12
|
+
|
|
13
|
+
validates :revision, presence: true, length: { maximum: MAX_REVISION_LENGTH }
|
|
6
14
|
validates :started_at, presence: true
|
|
15
|
+
validate :started_at_is_not_in_the_future
|
|
16
|
+
validate :metadata_is_within_size_limit
|
|
7
17
|
|
|
8
18
|
scope :for_range, ->(start_time, end_time) { where(started_at: start_time..end_time) }
|
|
9
19
|
scope :recent, -> { order(started_at: :desc) }
|
|
@@ -16,6 +26,26 @@ module RailsPulse
|
|
|
16
26
|
[]
|
|
17
27
|
end
|
|
18
28
|
|
|
29
|
+
# Breadcrumbs fall back to #to_s without this, which renders the object's
|
|
30
|
+
# inspect string. Revisions can be full 40-char SHAs, so truncate.
|
|
31
|
+
def to_breadcrumb
|
|
32
|
+
short_revision
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def short_revision
|
|
36
|
+
revision.to_s.length > 12 ? revision.to_s.first(12) : revision.to_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def in_progress?
|
|
40
|
+
finished_at.blank?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wall-clock seconds the deploy took, or nil while it is still running.
|
|
44
|
+
def duration
|
|
45
|
+
return nil if in_progress? || started_at.blank?
|
|
46
|
+
finished_at - started_at
|
|
47
|
+
end
|
|
48
|
+
|
|
19
49
|
def metadata_hash
|
|
20
50
|
return {} if metadata.blank?
|
|
21
51
|
JSON.parse(metadata)
|
|
@@ -26,5 +56,21 @@ module RailsPulse
|
|
|
26
56
|
def to_chart_marker
|
|
27
57
|
{ timestamp: started_at.to_i * 1000, revision: revision, started_at: started_at.iso8601 }
|
|
28
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def started_at_is_not_in_the_future
|
|
63
|
+
return if started_at.blank?
|
|
64
|
+
return if started_at <= MAX_CLOCK_SKEW.from_now
|
|
65
|
+
|
|
66
|
+
errors.add(:started_at, "can't be more than #{MAX_CLOCK_SKEW.inspect} in the future")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def metadata_is_within_size_limit
|
|
70
|
+
return if metadata.blank?
|
|
71
|
+
return if metadata.to_s.bytesize <= MAX_METADATA_BYTES
|
|
72
|
+
|
|
73
|
+
errors.add(:metadata, "is too large (maximum is #{MAX_METADATA_BYTES.to_i / 1024} KB)")
|
|
74
|
+
end
|
|
29
75
|
end
|
|
30
76
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class ExceptionGroup < RailsPulse::ApplicationRecord
|
|
3
|
+
self.table_name = "rails_pulse_exception_groups"
|
|
4
|
+
|
|
5
|
+
STATUSES = %w[open resolved ignored].freeze
|
|
6
|
+
|
|
7
|
+
# SQLite schema dumps omit boolean DEFAULT false; without a model default,
|
|
8
|
+
# AR inserts NULL and SQLite rejects the NOT NULL constraint.
|
|
9
|
+
attribute :preserve, :boolean, default: false
|
|
10
|
+
|
|
11
|
+
has_many :occurrences, class_name: "RailsPulse::ExceptionOccurrence",
|
|
12
|
+
foreign_key: :exception_group_id, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
validates :fingerprint, presence: true
|
|
15
|
+
validates :exception_class, presence: true
|
|
16
|
+
validates :first_seen_at, :last_seen_at, presence: true
|
|
17
|
+
validates :status, inclusion: { in: STATUSES }
|
|
18
|
+
|
|
19
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
20
|
+
scope :by_class, ->(klass) { where(exception_class: klass) }
|
|
21
|
+
scope :open, -> { where(status: "open") }
|
|
22
|
+
scope :resolved, -> { where(status: "resolved") }
|
|
23
|
+
scope :preservable, -> { where(preserve: false) }
|
|
24
|
+
|
|
25
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
26
|
+
%w[id exception_class fingerprint location first_seen_at last_seen_at occurrence_count status resolved_at preserve]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.ransackable_associations(auth_object = nil)
|
|
30
|
+
%w[occurrences]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_breadcrumb
|
|
34
|
+
exception_class
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class ExceptionOccurrence < RailsPulse::ApplicationRecord
|
|
3
|
+
self.table_name = "rails_pulse_exception_occurrences"
|
|
4
|
+
|
|
5
|
+
belongs_to :exception_group, class_name: "RailsPulse::ExceptionGroup"
|
|
6
|
+
|
|
7
|
+
serialize :backtrace, type: Array, coder: JSON
|
|
8
|
+
serialize :request_params, type: Hash, coder: JSON
|
|
9
|
+
|
|
10
|
+
validates :exception_class, presence: true
|
|
11
|
+
validates :occurred_at, presence: true
|
|
12
|
+
|
|
13
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
14
|
+
|
|
15
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
16
|
+
%w[id exception_class message occurred_at request_url environment]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.ransackable_associations(auth_object = nil)
|
|
20
|
+
%w[exception_group]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_breadcrumb
|
|
24
|
+
occurred_at.getlocal.strftime("%b %d, %Y %l:%M %p")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|