rails_pulse 0.4.2 → 0.5.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 +58 -1
- data/README.md +15 -4
- data/app/assets/stylesheets/rails_pulse/components/chart.css +11 -0
- data/app/assets/stylesheets/rails_pulse/components/descriptive_list.css +19 -1
- data/app/assets/stylesheets/rails_pulse/components/row.css +3 -17
- data/app/controllers/concerns/chart_table_concern.rb +28 -69
- data/app/controllers/concerns/deployment_markers_concern.rb +3 -1
- data/app/controllers/concerns/metric_card_concern.rb +6 -7
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +133 -0
- data/app/controllers/rails_pulse/api/v1/capabilities_controller.rb +27 -0
- data/app/controllers/rails_pulse/api/v1/coverage_controller.rb +158 -0
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +40 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +77 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/job_runs_controller.rb +34 -0
- data/app/controllers/rails_pulse/api/v1/jobs_controller.rb +172 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +123 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +74 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +103 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +25 -21
- data/app/controllers/rails_pulse/deployments_controller.rb +23 -13
- data/app/controllers/rails_pulse/exceptions_controller.rb +10 -12
- data/app/controllers/rails_pulse/jobs_controller.rb +1 -2
- data/app/controllers/rails_pulse/queries_controller.rb +6 -5
- data/app/controllers/rails_pulse/requests_controller.rb +15 -14
- data/app/controllers/rails_pulse/routes_controller.rb +4 -8
- data/app/helpers/rails_pulse/application_helper.rb +15 -0
- data/app/helpers/rails_pulse/chart_helper.rb +9 -2
- data/app/helpers/rails_pulse/formatting_helper.rb +11 -12
- data/app/javascript/rails_pulse/controllers/chart_controller.js +80 -43
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +4 -2
- data/app/jobs/rails_pulse/cloud_health_job.rb +11 -0
- data/app/jobs/rails_pulse/cloud_sync_job.rb +11 -0
- data/app/jobs/rails_pulse/summary_job.rb +13 -0
- data/app/models/concerns/rails_pulse/has_metadata.rb +15 -0
- data/app/models/concerns/rails_pulse/taggable.rb +5 -12
- data/app/models/rails_pulse/cards/base.rb +20 -11
- data/app/models/rails_pulse/charts/base.rb +5 -18
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -5
- data/app/models/rails_pulse/cleanup_run.rb +32 -0
- data/app/models/rails_pulse/cloud/buffered_batch.rb +55 -0
- data/app/models/rails_pulse/cloud/installation.rb +55 -0
- data/app/models/rails_pulse/dashboard/charts/response_time_percentiles.rb +4 -5
- data/app/models/rails_pulse/dashboard/charts/throughput_and_errors.rb +4 -5
- data/app/models/rails_pulse/dashboard/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +25 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +38 -13
- data/app/models/rails_pulse/dashboard/needs_attention.rb +31 -98
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +39 -63
- data/app/models/rails_pulse/dashboard/storage_status.rb +130 -39
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +41 -0
- data/app/models/rails_pulse/exception_occurrence.rb +1 -1
- data/app/models/rails_pulse/exceptions/cards/exception_rate.rb +3 -6
- data/app/models/rails_pulse/exceptions/cards/open_groups.rb +1 -4
- data/app/models/rails_pulse/exceptions/cards/total_occurrences.rb +3 -6
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +4 -7
- data/app/models/rails_pulse/job_run.rb +1 -1
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/p95_duration.rb +8 -14
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +5 -11
- data/app/models/rails_pulse/jobs/charts/duration.rb +1 -2
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -2
- data/app/models/rails_pulse/like_pattern.rb +35 -0
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/cards/database_load.rb +0 -9
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +5 -11
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +6 -12
- data/app/models/rails_pulse/queries/charts/database_load.rb +5 -8
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/request.rb +1 -1
- data/app/models/rails_pulse/routes/cards/error_rates.rb +5 -15
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +5 -17
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +5 -17
- data/app/models/rails_pulse/routes/charts/error_rate.rb +1 -1
- data/app/models/rails_pulse/routes/charts/request_volume.rb +1 -1
- data/app/models/rails_pulse/summary.rb +5 -0
- data/app/models/rails_pulse/tables/base.rb +5 -6
- data/app/models/rails_pulse/time_range.rb +309 -0
- data/app/models/rails_pulse/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +90 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_group_serializer.rb +23 -0
- data/app/serializers/rails_pulse/api/v1/exception_occurrence_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/job_run_serializer.rb +28 -0
- data/app/serializers/rails_pulse/api/v1/job_serializer.rb +26 -0
- data/app/serializers/rails_pulse/api/v1/query_serializer.rb +25 -0
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +22 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/cloud/batch.rb +83 -0
- data/app/services/rails_pulse/cloud/client.rb +68 -0
- data/app/services/rails_pulse/cloud/combined_metrics.rb +49 -0
- data/app/services/rails_pulse/cloud/health_item.rb +77 -0
- data/app/services/rails_pulse/cloud/path_prefix.rb +50 -0
- data/app/services/rails_pulse/cloud/preview.rb +83 -0
- data/app/services/rails_pulse/cloud/query_shape.rb +195 -0
- data/app/services/rails_pulse/cloud/record_items.rb +60 -0
- data/app/services/rails_pulse/cloud/route_pattern.rb +111 -0
- data/app/services/rails_pulse/cloud/summary_items.rb +188 -0
- data/app/services/rails_pulse/cloud/sync.rb +275 -0
- data/app/services/rails_pulse/config_recommendations.rb +132 -0
- data/app/services/rails_pulse/deployment_comparison.rb +177 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/change_point.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- data/app/services/rails_pulse/period_insights.rb +131 -0
- data/app/services/rails_pulse/sql_query_normalizer.rb +222 -122
- data/app/services/rails_pulse/summary_service/from_child_periods.rb +161 -0
- data/app/services/rails_pulse/summary_service/from_raw_rows.rb +115 -0
- data/app/services/rails_pulse/summary_service/metrics.rb +38 -0
- data/app/services/rails_pulse/summary_service.rb +121 -133
- data/app/services/rails_pulse/tag_filter_service.rb +5 -5
- data/app/views/layouts/rails_pulse/_time_range_selector.html.erb +4 -1
- data/app/views/rails_pulse/components/_operation_details_popover.html.erb +1 -1
- data/app/views/rails_pulse/components/_table_head.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_chart_panel.html.erb +6 -1
- data/app/views/rails_pulse/dashboard/_deployments_panel.html.erb +2 -2
- data/app/views/rails_pulse/dashboard/_health_badge.html.erb +29 -20
- data/app/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/dashboard/index.html.erb +16 -2
- data/app/views/rails_pulse/jobs/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/jobs/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/queries/index.html.erb +1 -1
- data/app/views/rails_pulse/queries/show.html.erb +1 -1
- data/app/views/rails_pulse/requests/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/_chart_tabs.html.erb +1 -0
- data/app/views/rails_pulse/routes/index.html.erb +1 -1
- data/app/views/rails_pulse/routes/show.html.erb +1 -1
- data/app/views/rails_pulse/shared/_aggregation_zone_badge.html.erb +4 -0
- data/app/views/rails_pulse/storage/show.html.erb +39 -2
- data/config/routes.rb +23 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/db/rails_pulse_migrate/20260506000001_create_rails_pulse_exceptions.rb +1 -1
- data/db/rails_pulse_migrate/20260921000001_create_rails_pulse_events.rb +21 -0
- data/db/rails_pulse_schema.rb +20 -2
- data/exe/rails-pulse +7 -0
- data/lib/generators/rails_pulse/base_methods.rb +19 -0
- data/lib/generators/rails_pulse/install_cloud_generator.rb +84 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +71 -19
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +37 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +89 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +182 -0
- data/lib/rails_pulse/cli/base_command.rb +37 -0
- data/lib/rails_pulse/cli/client.rb +64 -0
- data/lib/rails_pulse/cli/config.rb +87 -0
- data/lib/rails_pulse/cli/configure.rb +75 -0
- data/lib/rails_pulse/cli/coverage.rb +91 -0
- data/lib/rails_pulse/cli/deployments.rb +54 -0
- data/lib/rails_pulse/cli/exceptions.rb +116 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/install.rb +145 -0
- data/lib/rails_pulse/cli/job_runs.rb +53 -0
- data/lib/rails_pulse/cli/jobs.rb +71 -0
- data/lib/rails_pulse/cli/main.rb +76 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +67 -0
- data/lib/rails_pulse/cli/requests.rb +57 -0
- data/lib/rails_pulse/cli/routes.rb +73 -0
- data/lib/rails_pulse/configuration.rb +100 -2
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +78 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +117 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +104 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +128 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +142 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +108 -0
- data/lib/rails_pulse/mcp/tools/slow_requests.rb +118 -0
- data/lib/rails_pulse/middleware/request_collector.rb +9 -9
- data/lib/rails_pulse/scoped_inflector.rb +35 -0
- data/lib/rails_pulse/statistics.rb +27 -0
- data/lib/rails_pulse/subscribers/exception_subscriber.rb +2 -2
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +15 -29
- data/lib/rails_pulse/tasks/status_reporter.rb +201 -0
- data/lib/rails_pulse/tracker.rb +76 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +123 -24
- data/app/controllers/concerns/page_timings.rb +0 -10
- data/app/controllers/concerns/response_range_concern.rb +0 -45
- data/app/controllers/concerns/time_range_concern.rb +0 -152
- data/app/controllers/concerns/zoom_range_concern.rb +0 -86
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Resolves the dashboard's time range, chart-zoom/table window, and
|
|
3
|
+
# duration-threshold filter from request params and session state, in one
|
|
4
|
+
# pass. Returns a single frozen Result so every caller — controllers,
|
|
5
|
+
# views, cards, charts — reads the same object instead of each re-deriving
|
|
6
|
+
# its own answer.
|
|
7
|
+
#
|
|
8
|
+
# Priority order for the chart/page window (highest first):
|
|
9
|
+
# 1. Page-specific preset from the dropdown (params[:q][:period_start_range])
|
|
10
|
+
# 2. Page-specific custom range from the picker (params[:q][:custom_date_range])
|
|
11
|
+
# 3. Chart zoom / drill-down params (params[:q][:occurred_at_gteq/_lt])
|
|
12
|
+
# 4. Time range selector preference (session[:time_range_preference])
|
|
13
|
+
# 5. Global filters (session[:global_filters]["start_time"/"end_time"])
|
|
14
|
+
# 6. Default (default_key)
|
|
15
|
+
#
|
|
16
|
+
# Every input is parsed in Time.zone, matching how Summary rows are
|
|
17
|
+
# bucketed — parsing a custom-range string in any other zone would
|
|
18
|
+
# misinterpret the intended instant whenever that zone differs from
|
|
19
|
+
# Time.zone, silently resolving to the wrong window.
|
|
20
|
+
class TimeRange
|
|
21
|
+
Result = Struct.new(
|
|
22
|
+
:window, :table_window, :period_type, :selected_time_range,
|
|
23
|
+
:zoom_start, :zoom_end, :start_duration, :selected_response_range,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
PRESET_START = {
|
|
28
|
+
"last_24_hours" => -> { 1.day.ago },
|
|
29
|
+
"last_7_days" => -> { 1.week.ago },
|
|
30
|
+
"last_14_days" => -> { 2.weeks.ago },
|
|
31
|
+
"last_30_days" => -> { 1.month.ago }
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# Human-readable label for the zone daily/weekly/monthly summaries are
|
|
35
|
+
# bucketed in and custom ranges are rounded in (see class comment above)
|
|
36
|
+
# — the host app's config.time_zone, not the browser's zone. "UTC" or,
|
|
37
|
+
# e.g., "Eastern Time (US & Canada) (UTC-04:00)".
|
|
38
|
+
#
|
|
39
|
+
# This reads the Time.zone of the current dashboard request and assumes
|
|
40
|
+
# it is the zone SummaryJob ran under. That holds when both use
|
|
41
|
+
# config.time_zone (decision 0020). A host that overrides Time.zone per
|
|
42
|
+
# request for the dashboard as well would see a label that names the
|
|
43
|
+
# display zone while the daily buckets were still cut in the job's zone.
|
|
44
|
+
def self.aggregation_zone_label
|
|
45
|
+
return "UTC" if utc_zone?
|
|
46
|
+
"#{Time.zone.name} (UTC#{Time.zone.now.formatted_offset})"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Compact form of aggregation_zone_label for chart headings and tooltip
|
|
50
|
+
# suffixes, where the full name would crowd out the data: "UTC", the
|
|
51
|
+
# zone's abbreviation when it has an alphabetic one ("EDT"), otherwise
|
|
52
|
+
# the offset ("UTC+05:45").
|
|
53
|
+
def self.aggregation_zone_short_label
|
|
54
|
+
return "UTC" if utc_zone?
|
|
55
|
+
abbreviation = Time.zone.now.zone
|
|
56
|
+
abbreviation.match?(/\A[A-Z]{2,5}\z/) ? abbreviation : "UTC#{Time.zone.now.formatted_offset}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# IANA identifier for the same zone, for the chart JS (Intl.DateTimeFormat)
|
|
60
|
+
# to render daily labels in the zone summaries were actually bucketed in
|
|
61
|
+
# instead of the browser's zone.
|
|
62
|
+
def self.aggregation_zone_iana
|
|
63
|
+
Time.zone.tzinfo.name
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.utc_zone?
|
|
67
|
+
Time.zone.utc_offset.zero? && Time.zone.now.zone == "UTC"
|
|
68
|
+
end
|
|
69
|
+
private_class_method :utc_zone?
|
|
70
|
+
|
|
71
|
+
def self.resolve(params:, session:, default_key: :last_24_hours, duration_range_type: :route)
|
|
72
|
+
new(params: params, session: session, default_key: default_key, duration_range_type: duration_range_type).resolve
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def initialize(params:, session:, default_key:, duration_range_type:)
|
|
76
|
+
@params = params
|
|
77
|
+
@session = session
|
|
78
|
+
@default_key = default_key
|
|
79
|
+
@duration_range_type = duration_range_type
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve
|
|
83
|
+
start_time, end_time, selected_time_range = resolve_window
|
|
84
|
+
start_time, end_time, time_diff_hours = normalize(start_time, end_time)
|
|
85
|
+
period_type = time_diff_hours <= 25 ? "hour" : "day"
|
|
86
|
+
|
|
87
|
+
zoom_start, zoom_end, table_start, table_end = resolve_zoom(start_time.to_i, end_time.to_i)
|
|
88
|
+
start_duration, selected_response_range = resolve_duration
|
|
89
|
+
|
|
90
|
+
Result.new(
|
|
91
|
+
window: RailsPulse::TimeWindow.new(start_time, end_time),
|
|
92
|
+
table_window: RailsPulse::TimeWindow.new(table_start, table_end),
|
|
93
|
+
period_type: period_type,
|
|
94
|
+
selected_time_range: selected_time_range.to_s,
|
|
95
|
+
zoom_start: zoom_start,
|
|
96
|
+
zoom_end: zoom_end,
|
|
97
|
+
start_duration: start_duration,
|
|
98
|
+
selected_response_range: selected_response_range
|
|
99
|
+
).freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
attr_reader :params, :session, :default_key, :duration_range_type
|
|
105
|
+
|
|
106
|
+
# -- Window resolution (priorities 1-6) ----------------------------------
|
|
107
|
+
|
|
108
|
+
def resolve_window
|
|
109
|
+
from_page_preset || from_page_custom_range || from_chart_zoom ||
|
|
110
|
+
from_session_custom || from_session_preset || from_global_filters ||
|
|
111
|
+
default_window
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def default_window
|
|
115
|
+
[ preset_start(default_key), Time.zone.now, default_key ]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def preset_start(key)
|
|
119
|
+
(PRESET_START[key.to_s] || PRESET_START["last_24_hours"]).call
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Priority 1: page-specific preset from the dropdown.
|
|
123
|
+
def from_page_preset
|
|
124
|
+
range = ransack_params[:period_start_range]
|
|
125
|
+
return nil unless range.present? && range.to_sym != :custom
|
|
126
|
+
|
|
127
|
+
[ preset_start(range), Time.zone.now, range ]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Priority 2: page-specific custom datetime range from the picker (only
|
|
131
|
+
# when period_start_range is explicitly "custom").
|
|
132
|
+
def from_page_custom_range
|
|
133
|
+
return nil unless ransack_params[:period_start_range].present? &&
|
|
134
|
+
ransack_params[:period_start_range].to_sym == :custom &&
|
|
135
|
+
ransack_params[:custom_date_range].present? &&
|
|
136
|
+
ransack_params[:custom_date_range].include?(" to ")
|
|
137
|
+
|
|
138
|
+
dates = ransack_params[:custom_date_range].split(" to ")
|
|
139
|
+
custom_start = parse_time_param(dates[0].strip)
|
|
140
|
+
custom_end = parse_time_param(dates[1].strip)
|
|
141
|
+
return nil unless custom_start && custom_end
|
|
142
|
+
|
|
143
|
+
[ custom_start, custom_end, :custom ]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Priority 3: custom time range from chart zoom.
|
|
147
|
+
def from_chart_zoom
|
|
148
|
+
return nil unless ransack_params[:occurred_at_gteq].present? && ransack_params[:occurred_at_lt].present?
|
|
149
|
+
|
|
150
|
+
zoom_start = parse_time_param(ransack_params[:occurred_at_gteq])
|
|
151
|
+
zoom_end = parse_time_param(ransack_params[:occurred_at_lt])
|
|
152
|
+
return nil unless zoom_start && zoom_end
|
|
153
|
+
|
|
154
|
+
[ zoom_start, zoom_end, :custom ]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Priority 4a: custom range from the time range selector (session).
|
|
158
|
+
def from_session_custom
|
|
159
|
+
preference = session_time_range_preference
|
|
160
|
+
return nil unless RailsPulse::TimeRangePreference.custom?(preference)
|
|
161
|
+
|
|
162
|
+
start_time = parse_time_param(preference["start_time"]) || preset_start(default_key)
|
|
163
|
+
end_time = parse_time_param(preference["end_time"]) || Time.zone.now
|
|
164
|
+
[ start_time, end_time, :custom ]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Priority 4b: preset from the time range selector (session).
|
|
168
|
+
def from_session_preset
|
|
169
|
+
preference = session_time_range_preference
|
|
170
|
+
return nil unless preference.is_a?(String) && preference.present?
|
|
171
|
+
|
|
172
|
+
[ preset_start(preference), Time.zone.now, preference.to_sym ]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Priority 5: global filters (session).
|
|
176
|
+
def from_global_filters
|
|
177
|
+
filters = session_global_filters
|
|
178
|
+
return nil unless filters["start_time"].present? || filters["end_time"].present?
|
|
179
|
+
|
|
180
|
+
start_time = parse_time_param(filters["start_time"]) || preset_start(default_key)
|
|
181
|
+
end_time = parse_time_param(filters["end_time"]) || Time.zone.now
|
|
182
|
+
[ start_time, end_time, :custom ]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ransack_params
|
|
186
|
+
q = params[:q]
|
|
187
|
+
q.respond_to?(:permit) ? q : ActionController::Parameters.new({})
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def session_time_range_preference
|
|
191
|
+
RailsPulse::TimeRangePreference.normalize(session[:time_range_preference])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def session_global_filters
|
|
195
|
+
filters = session[:global_filters]
|
|
196
|
+
filters.is_a?(Hash) ? filters : {}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Returns nil for anything that does not parse, so a hand-edited or stale
|
|
200
|
+
# value falls back to the default range instead of raising.
|
|
201
|
+
def parse_time_param(param)
|
|
202
|
+
case param
|
|
203
|
+
when Time, DateTime
|
|
204
|
+
param.in_time_zone
|
|
205
|
+
when String
|
|
206
|
+
return nil if param.blank?
|
|
207
|
+
|
|
208
|
+
Time.zone.parse(param)
|
|
209
|
+
when Numeric
|
|
210
|
+
Time.zone.at(param)
|
|
211
|
+
else
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
rescue ArgumentError, TypeError, RangeError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Rounds to hour or day boundaries in Time.zone (summaries are always
|
|
219
|
+
# bucketed there) and returns the span in hours (float) alongside.
|
|
220
|
+
def normalize(start_time, end_time)
|
|
221
|
+
start_time = start_time.in_time_zone
|
|
222
|
+
end_time = end_time.in_time_zone
|
|
223
|
+
time_diff_hours = (end_time.to_i - start_time.to_i) / 3600.0
|
|
224
|
+
|
|
225
|
+
if time_diff_hours <= 25
|
|
226
|
+
[ start_time.beginning_of_hour, end_time.end_of_hour, time_diff_hours ]
|
|
227
|
+
else
|
|
228
|
+
[ start_time.beginning_of_day, end_time.end_of_day, time_diff_hours ]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# -- Zoom / table window --------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def resolve_zoom(main_start, main_end)
|
|
235
|
+
selected_column_time = params[:selected_column_time]
|
|
236
|
+
zoom_start = params.delete(:zoom_start_time)
|
|
237
|
+
zoom_end = params.delete(:zoom_end_time)
|
|
238
|
+
|
|
239
|
+
if selected_column_time
|
|
240
|
+
table_start, table_end = normalize_column_time(selected_column_time.to_i, main_start, main_end)
|
|
241
|
+
# Column selection has highest precedence for the table only; the
|
|
242
|
+
# chart keeps showing the full range, so zoom_start/zoom_end are
|
|
243
|
+
# returned as extracted (not normalized) rather than derived here.
|
|
244
|
+
return [ zoom_start, zoom_end, table_start, table_end ]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
zoom_start, zoom_end = normalize_zoom_times(zoom_start.to_i, zoom_end.to_i) if zoom_start && zoom_end
|
|
248
|
+
|
|
249
|
+
table_start = zoom_start || main_start
|
|
250
|
+
table_end = zoom_end || main_end
|
|
251
|
+
|
|
252
|
+
[ zoom_start, zoom_end, table_start, table_end ]
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# column_time_ms is JS milliseconds; period boundary is based on the
|
|
256
|
+
# overall page range (main_start/main_end), same as the chart.
|
|
257
|
+
def normalize_column_time(column_time_ms, main_start, main_end)
|
|
258
|
+
column_time_seconds = column_time_ms / 1000
|
|
259
|
+
time_diff_hours = (main_end - main_start) / 3600.0
|
|
260
|
+
column_time_obj = Time.zone&.at(column_time_seconds) || Time.at(column_time_seconds)
|
|
261
|
+
|
|
262
|
+
if time_diff_hours <= 25
|
|
263
|
+
[ column_time_obj.beginning_of_hour.to_i, column_time_obj.end_of_hour.to_i ]
|
|
264
|
+
else
|
|
265
|
+
[ column_time_obj.beginning_of_day.to_i, column_time_obj.end_of_day.to_i ]
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# start_ms/end_ms are JS milliseconds; period boundary is based on the
|
|
270
|
+
# zoomed span itself.
|
|
271
|
+
def normalize_zoom_times(start_ms, end_ms)
|
|
272
|
+
start_seconds = start_ms / 1000
|
|
273
|
+
end_seconds = end_ms / 1000
|
|
274
|
+
time_diff_hours = (end_seconds - start_seconds) / 3600.0
|
|
275
|
+
start_obj = Time.zone&.at(start_seconds) || Time.at(start_seconds)
|
|
276
|
+
end_obj = Time.zone&.at(end_seconds) || Time.at(end_seconds)
|
|
277
|
+
|
|
278
|
+
if time_diff_hours <= 25
|
|
279
|
+
[ start_obj.beginning_of_hour.to_i, end_obj.end_of_hour.to_i ]
|
|
280
|
+
else
|
|
281
|
+
[ start_obj.beginning_of_day.to_i, end_obj.end_of_day.to_i ]
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# -- Duration threshold -----------------------------------------------------
|
|
286
|
+
|
|
287
|
+
def resolve_duration
|
|
288
|
+
thresholds = RailsPulse.configuration.public_send("#{duration_range_type}_thresholds")
|
|
289
|
+
duration_param = ransack_params[:avg_duration] || ransack_params[:duration] || ransack_params[:duration_gteq]
|
|
290
|
+
|
|
291
|
+
if duration_param.present?
|
|
292
|
+
[ threshold_for(thresholds, duration_param), duration_param ]
|
|
293
|
+
elsif (global_threshold = session_global_filters["performance_threshold"]).present?
|
|
294
|
+
[ threshold_for(thresholds, global_threshold), global_threshold.to_sym ]
|
|
295
|
+
else
|
|
296
|
+
[ 0, :all ]
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def threshold_for(thresholds, key)
|
|
301
|
+
case key.to_sym
|
|
302
|
+
when :slow then thresholds[:slow]
|
|
303
|
+
when :very_slow then thresholds[:very_slow]
|
|
304
|
+
when :critical then thresholds[:critical]
|
|
305
|
+
else 0
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -45,6 +45,21 @@ module RailsPulse
|
|
|
45
45
|
starts
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# The start of each chart bucket from the window's start, as epoch
|
|
49
|
+
# seconds: one calendar day or one hour apart in Time.zone. A day is 23
|
|
50
|
+
# or 25 hours across a daylight-saving change, so stepping a fixed 86,400
|
|
51
|
+
# seconds would drift off local midnight and miss every summary after it.
|
|
52
|
+
def bucket_timestamps(period_type)
|
|
53
|
+
step = period_type.to_s == "hour" ? 1.hour : 1.day
|
|
54
|
+
stamps = []
|
|
55
|
+
current = start_time
|
|
56
|
+
while current <= end_time
|
|
57
|
+
stamps << current.to_i
|
|
58
|
+
current += step
|
|
59
|
+
end
|
|
60
|
+
stamps
|
|
61
|
+
end
|
|
62
|
+
|
|
48
63
|
# The window of the same length immediately preceding this one, for
|
|
49
64
|
# period-over-period comparisons. `unit` is "day" or "hour".
|
|
50
65
|
def previous(unit)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# The background writer's once-a-minute sample, stored as an Event of kind
|
|
3
|
+
# writer_heartbeat: `subject` is "host:pid", `value` the requests dropped
|
|
4
|
+
# since the previous sample, and `metadata` the queue depth, its configured
|
|
5
|
+
# capacity and the lifetime drop count. Stats are otherwise per process, so
|
|
6
|
+
# with eight Puma workers the dashboard could only ever see the one that
|
|
7
|
+
# happened to serve it; these rows let it, the Storage page and
|
|
8
|
+
# rails_pulse:status add every live writer up.
|
|
9
|
+
class WriterHeartbeat
|
|
10
|
+
KIND = "writer_heartbeat".freeze
|
|
11
|
+
# A writer that has not reported for this long is treated as gone.
|
|
12
|
+
LIVE_WINDOW = 3.minutes
|
|
13
|
+
RETENTION = 24.hours
|
|
14
|
+
|
|
15
|
+
Sample = Struct.new(:hostname, :pid, :queue_size, :queue_depth, :dropped_total, :sampled_at, keyword_init: true) do
|
|
16
|
+
def process_label
|
|
17
|
+
"#{hostname}:#{pid}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def events
|
|
23
|
+
RailsPulse::Event.of_kind(KIND)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `host_label` is config.cloud.host_label: Rails Pulse Cloud names the
|
|
27
|
+
# host by it instead of the hostname, which can show internal addresses
|
|
28
|
+
# and changes with every container.
|
|
29
|
+
def record!(hostname:, pid:, queue_size:, queue_depth:, dropped:, dropped_total:, host_label: nil, sampled_at: Time.current)
|
|
30
|
+
RailsPulse::Event.insert_all([ {
|
|
31
|
+
kind: KIND,
|
|
32
|
+
subject: "#{hostname}:#{pid}",
|
|
33
|
+
outcome: "sampled",
|
|
34
|
+
value: dropped,
|
|
35
|
+
occurred_at: sampled_at,
|
|
36
|
+
metadata: { hostname: hostname, host_label: host_label, pid: pid, queue_size: queue_size, queue_depth: queue_depth,
|
|
37
|
+
dropped_total: dropped_total }.compact.to_json,
|
|
38
|
+
created_at: sampled_at,
|
|
39
|
+
updated_at: sampled_at
|
|
40
|
+
} ])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def prune!
|
|
44
|
+
events.where(occurred_at: ...RETENTION.ago).delete_all
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The latest sample from each writer that reported inside LIVE_WINDOW,
|
|
48
|
+
# newest first. A handful of rows per process, reduced in Ruby.
|
|
49
|
+
def live_processes
|
|
50
|
+
events.since(LIVE_WINDOW.ago).recent.to_a.uniq(&:subject).map { |event| sample_from(event) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# { "host:pid" => requests dropped inside `window` }
|
|
54
|
+
def dropped_by_process(window: 1.hour)
|
|
55
|
+
events.since(window.ago).group(:subject).sum(:value).transform_values(&:to_i)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# What the dashboard, Storage page and status task show:
|
|
59
|
+
# processes live writers
|
|
60
|
+
# queue_depth requests waiting across them, from each one's latest sample
|
|
61
|
+
# queue_size the largest configured capacity among them
|
|
62
|
+
# dropped requests dropped inside `window`, across every process
|
|
63
|
+
# last_sampled_at most recent heartbeat, nil when there is none
|
|
64
|
+
def summary(window: 1.hour)
|
|
65
|
+
processes = live_processes
|
|
66
|
+
{
|
|
67
|
+
processes: processes.size,
|
|
68
|
+
queue_depth: processes.sum(&:queue_depth),
|
|
69
|
+
queue_size: processes.map(&:queue_size).max || 0,
|
|
70
|
+
dropped: events.since(window.ago).sum(:value).to_i,
|
|
71
|
+
last_sampled_at: events.maximum(:occurred_at)
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def sample_from(event)
|
|
78
|
+
meta = event.metadata_hash
|
|
79
|
+
Sample.new(
|
|
80
|
+
hostname: meta["hostname"],
|
|
81
|
+
pid: meta["pid"],
|
|
82
|
+
queue_size: meta["queue_size"].to_i,
|
|
83
|
+
queue_depth: meta["queue_depth"].to_i,
|
|
84
|
+
dropped_total: meta["dropped_total"].to_i,
|
|
85
|
+
sampled_at: event.occurred_at
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class DeploymentSerializer
|
|
5
|
+
# `comparison` is the deployment's DeploymentComparison, computed by
|
|
6
|
+
# the caller so a page of deployments is compared in one query.
|
|
7
|
+
def self.serialize(deployment, comparison: nil)
|
|
8
|
+
{
|
|
9
|
+
id: deployment.id,
|
|
10
|
+
revision: deployment.revision,
|
|
11
|
+
short_revision: deployment.short_revision,
|
|
12
|
+
started_at: deployment.started_at,
|
|
13
|
+
finished_at: deployment.finished_at,
|
|
14
|
+
duration_seconds: deployment.duration&.round(1),
|
|
15
|
+
in_progress: deployment.in_progress?,
|
|
16
|
+
metadata: deployment.metadata_hash,
|
|
17
|
+
comparison: comparison
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class ExceptionGroupSerializer
|
|
5
|
+
def self.serialize(group)
|
|
6
|
+
{
|
|
7
|
+
id: group.id,
|
|
8
|
+
fingerprint: group.fingerprint,
|
|
9
|
+
exception_class: group.exception_class,
|
|
10
|
+
location: group.location,
|
|
11
|
+
message: group.message,
|
|
12
|
+
status: group.status,
|
|
13
|
+
occurrence_count: group.occurrence_count,
|
|
14
|
+
first_seen_at: group.first_seen_at,
|
|
15
|
+
last_seen_at: group.last_seen_at,
|
|
16
|
+
resolved_at: group.resolved_at,
|
|
17
|
+
preserve: group.preserve
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class ExceptionOccurrenceSerializer
|
|
5
|
+
def self.serialize(occurrence)
|
|
6
|
+
{
|
|
7
|
+
id: occurrence.id,
|
|
8
|
+
exception_class: occurrence.exception_class,
|
|
9
|
+
message: occurrence.message,
|
|
10
|
+
occurred_at: occurrence.occurred_at,
|
|
11
|
+
request_method: occurrence.request_method,
|
|
12
|
+
request_url: occurrence.request_url,
|
|
13
|
+
request_params: occurrence.request_params.presence,
|
|
14
|
+
environment: occurrence.environment,
|
|
15
|
+
deploy_sha: occurrence.deploy_sha,
|
|
16
|
+
backtrace: Array(occurrence.backtrace)
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class JobRunSerializer
|
|
5
|
+
ERROR_MESSAGE_LIMIT = 500
|
|
6
|
+
|
|
7
|
+
def self.serialize(run)
|
|
8
|
+
{
|
|
9
|
+
id: run.id,
|
|
10
|
+
job_id: run.job_id,
|
|
11
|
+
job_name: run.job&.name,
|
|
12
|
+
queue_name: run.job&.queue_name,
|
|
13
|
+
run_id: run.run_id,
|
|
14
|
+
status: run.status,
|
|
15
|
+
occurred_at: run.occurred_at,
|
|
16
|
+
enqueued_at: run.enqueued_at,
|
|
17
|
+
duration: run.duration,
|
|
18
|
+
attempts: run.attempts,
|
|
19
|
+
adapter: run.adapter,
|
|
20
|
+
error_class: run.error_class,
|
|
21
|
+
error_message: run.error_message&.truncate(ERROR_MESSAGE_LIMIT),
|
|
22
|
+
tags: run.tags
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class JobSerializer
|
|
5
|
+
# Without `stats` the counters are the job's lifetime totals, cached on
|
|
6
|
+
# the row. With it they cover one window and come from summaries, so
|
|
7
|
+
# the two are kept in separate keys rather than one set of names
|
|
8
|
+
# meaning different things depending on the request.
|
|
9
|
+
def self.serialize(job, stats: nil)
|
|
10
|
+
{
|
|
11
|
+
id: job.id,
|
|
12
|
+
name: job.name,
|
|
13
|
+
queue_name: job.queue_name,
|
|
14
|
+
runs_count: job.runs_count,
|
|
15
|
+
failures_count: job.failures_count,
|
|
16
|
+
avg_duration: job.avg_duration,
|
|
17
|
+
p95_duration: job.p95_duration,
|
|
18
|
+
p99_duration: job.p99_duration,
|
|
19
|
+
failure_rate: job.failure_rate,
|
|
20
|
+
stats: stats
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class QuerySerializer
|
|
5
|
+
def self.serialize(query, stats: nil)
|
|
6
|
+
analysis = (query.n_plus_one_analysis || {}).transform_keys(&:to_s)
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
id: query.id,
|
|
10
|
+
normalized_sql: query.normalized_sql,
|
|
11
|
+
hashed_sql: query.hashed_sql,
|
|
12
|
+
analyzed_at: query.analyzed_at,
|
|
13
|
+
issues: query.issues,
|
|
14
|
+
suggestions: query.suggestions,
|
|
15
|
+
n_plus_one: {
|
|
16
|
+
likely: analysis["is_likely_n_plus_one"] == true,
|
|
17
|
+
confidence: analysis["confidence_score"]
|
|
18
|
+
},
|
|
19
|
+
stats: stats
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class RequestSerializer
|
|
5
|
+
def self.serialize(request)
|
|
6
|
+
{
|
|
7
|
+
id: request.id,
|
|
8
|
+
route_id: request.route_id,
|
|
9
|
+
path: request.route&.path,
|
|
10
|
+
occurred_at: request.occurred_at,
|
|
11
|
+
duration: request.duration,
|
|
12
|
+
status: request.status,
|
|
13
|
+
is_error: request.is_error,
|
|
14
|
+
request_uuid: request.request_uuid,
|
|
15
|
+
controller_action: request.controller_action,
|
|
16
|
+
response_size_bytes: request.response_size_bytes
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
class RouteSerializer
|
|
5
|
+
def self.serialize(route, stats: nil)
|
|
6
|
+
{
|
|
7
|
+
id: route.id,
|
|
8
|
+
http_methods: route.http_methods_list,
|
|
9
|
+
path: route.path,
|
|
10
|
+
controller_action: route.controller_action,
|
|
11
|
+
tags: route.tag_list,
|
|
12
|
+
created_at: route.created_at,
|
|
13
|
+
stats: stats
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|