rails_pulse 0.5.0.pre.1 → 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 +25 -0
- data/README.md +1 -1
- data/app/controllers/rails_pulse/api/v1/base_controller.rb +4 -1
- data/app/controllers/rails_pulse/api/v1/deployments_controller.rb +17 -1
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +1 -0
- data/app/controllers/rails_pulse/api/v1/insights_controller.rb +62 -0
- data/app/controllers/rails_pulse/api/v1/queries_controller.rb +10 -1
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +17 -1
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +6 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +7 -3
- 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/rails_pulse/charts/base.rb +3 -12
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +1 -2
- 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/concerns/attention_classification.rb +104 -0
- data/app/models/rails_pulse/dashboard/concerns/time_range_helper.rb +24 -0
- data/app/models/rails_pulse/dashboard/health_summary.rb +9 -11
- data/app/models/rails_pulse/dashboard/needs_attention.rb +30 -96
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +7 -61
- data/app/models/rails_pulse/dashboard/storage_status.rb +94 -39
- data/app/models/rails_pulse/event.rb +3 -2
- data/app/models/rails_pulse/exceptions/charts/occurrence_volume.rb +1 -3
- 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 +6 -0
- data/app/models/rails_pulse/queries/charts/database_load.rb +1 -3
- data/app/models/rails_pulse/queries/charts/execution_volume.rb +1 -1
- 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/time_window.rb +15 -0
- data/app/models/rails_pulse/writer_heartbeat.rb +6 -2
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +5 -2
- data/app/serializers/rails_pulse/api/v1/request_serializer.rb +1 -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/operations/change_point.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/views/rails_pulse/dashboard/_storage_panel.html.erb +13 -3
- data/app/views/rails_pulse/storage/show.html.erb +2 -2
- data/config/routes.rb +4 -0
- data/db/rails_pulse_cloud_schema.rb +58 -0
- data/lib/generators/rails_pulse/base_methods.rb +18 -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/migrations/install_rails_pulse_cloud_tables.rb +30 -0
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +35 -6
- data/lib/generators/rails_pulse/upgrade_generator.rb +9 -16
- data/lib/rails_pulse/cleanup_service.rb +11 -0
- data/lib/rails_pulse/cli/agent_files/agents.md +7 -5
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +20 -3
- data/lib/rails_pulse/cli/deployments.rb +16 -2
- data/lib/rails_pulse/cli/exceptions.rb +5 -0
- data/lib/rails_pulse/cli/insights.rb +70 -0
- data/lib/rails_pulse/cli/main.rb +4 -0
- data/lib/rails_pulse/cli/queries.rb +5 -0
- data/lib/rails_pulse/cli/requests.rb +9 -1
- data/lib/rails_pulse/cli/routes.rb +7 -0
- data/lib/rails_pulse/configuration.rb +77 -1
- data/lib/rails_pulse/installers/config_updater.rb +9 -2
- data/lib/rails_pulse/mcp/server.rb +3 -1
- data/lib/rails_pulse/mcp/tools/deployments.rb +47 -10
- data/lib/rails_pulse/mcp/tools/errors.rb +11 -2
- data/lib/rails_pulse/mcp/tools/exceptions.rb +16 -2
- data/lib/rails_pulse/mcp/tools/insights.rb +99 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +7 -1
- data/lib/rails_pulse/mcp/tools/routes.rb +12 -1
- data/lib/rails_pulse/tasks/status_reporter.rb +175 -0
- data/lib/rails_pulse/tracker.rb +2 -1
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/tasks/rails_pulse.rake +6 -0
- data/lib/tasks/rails_pulse_tasks.rake +7 -0
- metadata +30 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 208274704510167233904a19653cfe5bffcf6f29080344d088e9cbee8c425a87
|
|
4
|
+
data.tar.gz: fbbe9e3a6c35a09236a81158d47026a9ab913f1db6e79800b8c71183df4d9d63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83b22f6771f0c1a99bada73bd60e1af0abb7a7931b5523afa9c1a852c85bf7664d4af32ec025d955ff6db47dfde7c3b2563fee86301d2a7032b0a6f7f1615543
|
|
7
|
+
data.tar.gz: '08fd08858f4eafa689a17e92c922623f15f11ff9663de193b3f99830faa59a171bef0a7c5357dbb7bcd2df9afd1ab97f7f6c3ff52ca4cb0f67d7835f11c99730'
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0.pre.2] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Before-and-after comparison for every deployment.** The deployments API, `rails-pulse deployments list` and the `rails_pulse_deployments` tool now say whether each release made things worse: response time or error rate materially worse in the hour after the deploy finished than in the hour before it started, or too little traffic to tell.
|
|
15
|
+
- **What needs attention, and whether the thresholds fit.** `GET /rails_pulse/api/v1/insights`, `rails-pulse insights show` and the `rails_pulse_insights` tool list the routes, queries and jobs past their thresholds over one hour, day, week or month, and suggest an initializer line when `route_thresholds` or `query_thresholds` is too noisy or never fires.
|
|
16
|
+
- **Exact lookups for the keys Rails Pulse Cloud holds.** The API, CLI and MCP tools can now find a query by `hashed_sql`, an exception group by `fingerprint`, a route by exact path and controller action, a deployment by revision, and requests by path prefix, so a Cloud alert leads straight to the local detail.
|
|
17
|
+
- **Rails Pulse Cloud sync.** With `config.cloud.api_key` and `config.cloud.application` set, hourly summaries, exception groups, deployments and a per-minute health update are sent to Rails Pulse Cloud (never request URLs, SQL values or exception messages), and `rails rails_pulse:cloud:preview` prints exactly what would be sent; to use it, run `rails generate rails_pulse:install_cloud`, migrate, and schedule `RailsPulse::CloudHealthJob` every minute.
|
|
18
|
+
- **`rails rails_pulse:status` checks how the install is run.** It reports whether `SummaryJob` has skipped hours and whether `CleanupJob` is running, how long hourly summaries are kept, and whether jobs are tracked, and lists suggestions for each without changing the exit status. Each cleanup run is now recorded in `rails_pulse_events` so the task can tell.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **A query is the same query however many values it loads.** `WHERE id IN (…)` with 3 ids and with 12 ids, and a batch insert of 2 rows and of 50, used to be tracked as separate queries, each with its own stats, N+1 flags and EXPLAIN; they are now one entry. Entries recorded by earlier versions keep their old form until retention removes them, so a query may appear twice on the Queries page for a while after upgrading.
|
|
23
|
+
- **Hourly summaries are kept for 7 days instead of 2.** Deployment comparisons and hour-precise change points read them, so at 2 days a deploy from earlier in the week could no longer be compared; `config.hourly_summary_retention` tunes it.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Normalised SQL no longer shows some string values.** A value written in one of SQL's less common string forms — only possible in hand-written SQL — stayed visible in the normalised query on the dashboard, the API and the MCP server, and split that query's stats into one entry per value; it is now replaced with `?` like any other value. On MySQL the same happened to any value containing an apostrophe or any double-quoted value shaped like a column name, and on every database to very small or very large decimals. (#335, #313)
|
|
28
|
+
- **The dashboard no longer counts every Rails Pulse table on each load.** On PostgreSQL and MySQL its storage panel now shows the database's row estimates, and on SQLite it leaves record counts to the Storage page, so large installs no longer wait tens of seconds per page view; the storage-pressure checks also run once per page instead of four times. On MySQL 8, record counts and disk sizes on the dashboard and the Storage page are read current instead of from a statistics cache up to a day old. (#363)
|
|
29
|
+
- **Needs Attention and System Health no longer overcount traffic.** Request, error, query and exception counts were added up once per summary granularity, which inflated them and could raise an exception group's severity; today's traffic now counts in windows longer than a day. (#338)
|
|
30
|
+
- **Threshold recommendations no longer suggest a critical threshold equal to `very_slow` or below a rarely run slow query.** A suggested critical threshold now sits at least one step above `very_slow`, and the query check reads the period's slowest query rather than only its most expensive ones.
|
|
31
|
+
- **The dashboard no longer reports cleanup as blocked when it is not.** The critical "summarize first" Storage item appeared whenever expired requests were waiting for the next daily cleanup, which deletes them by age regardless of summaries; it has been removed, and a stalled summary job is still flagged.
|
|
32
|
+
- **PostgreSQL Unicode escape strings (`U&'…'`) are normalised to `?`** like other prefixed strings, instead of leaving `U&?` in the normalised query.
|
|
33
|
+
- **Daily charts no longer go blank after a daylight-saving change.** In a `config.time_zone` with daylight saving, every day after the clocks changed was drawn empty in a range that crossed the change; chart days now follow the zone's calendar.
|
|
34
|
+
|
|
10
35
|
## [0.5.0.pre.1] - 2026-10-03
|
|
11
36
|
|
|
12
37
|
### Added
|
data/README.md
CHANGED
|
@@ -120,7 +120,7 @@ rails-pulse routes list --since 2026-06-01T00:00:00Z
|
|
|
120
120
|
rails-pulse install claude # Claude Code skill: when and how to use the tools
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
Add `gem "mcp"` to your Gemfile (a development group is enough), register `rails-pulse mcp` as an MCP server, and the agent gets
|
|
123
|
+
Add `gem "mcp"` to your Gemfile (a development group is enough), register `rails-pulse mcp` as an MCP server, and the agent gets eleven read-only tools: routes, slow requests, errors, exception groups and their backtraces, one endpoint in depth, expensive and N+1 queries, job health, deployments and whether each one made things worse, what needs attention and whether your thresholds fit, and what has actually been recorded. Nothing the agent can call changes production. [Agent tooling](docs/api.md)
|
|
124
124
|
|
|
125
125
|
**Keep it in its own database.** `rails generate rails_pulse:install --database=separate` puts the tables somewhere your primary never has to vacuum. [Database setup](https://railspulse.com/documentation/database)
|
|
126
126
|
|
|
@@ -28,7 +28,10 @@ module RailsPulse
|
|
|
28
28
|
|
|
29
29
|
# Parameters every endpoint reads as one string. A repeated or nested
|
|
30
30
|
# one (`search[]=x`) is refused rather than reaching a String method.
|
|
31
|
-
SCALAR_PARAMS = %i[
|
|
31
|
+
SCALAR_PARAMS = %i[
|
|
32
|
+
limit offset min_requests occurrences since until search route status sort job period at
|
|
33
|
+
hashed_sql fingerprint path controller_action path_prefix revision
|
|
34
|
+
].freeze
|
|
32
35
|
INTEGER_PARAMS = %i[limit offset min_requests occurrences].freeze
|
|
33
36
|
|
|
34
37
|
# Past any real table, and inside a 64-bit integer on every adapter.
|
|
@@ -10,14 +10,30 @@ module RailsPulse
|
|
|
10
10
|
collection = RailsPulse::Deployment.recent.order(id: :desc)
|
|
11
11
|
collection = collection.where(started_at: since_start..) if since_start
|
|
12
12
|
collection = collection.where(started_at: ..until_end) if until_end
|
|
13
|
+
collection = apply_revision_filter(collection)
|
|
13
14
|
|
|
14
15
|
data, meta = paginated(collection)
|
|
16
|
+
deployments = data.to_a
|
|
17
|
+
comparisons = RailsPulse::DeploymentComparison.for(deployments)
|
|
15
18
|
|
|
16
19
|
render json: {
|
|
17
|
-
data:
|
|
20
|
+
data: deployments.map { |deployment| DeploymentSerializer.serialize(deployment, comparison: comparisons[deployment.id]) },
|
|
18
21
|
meta: meta
|
|
19
22
|
}
|
|
20
23
|
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# The full revision or the start of one, so the short SHA a person
|
|
28
|
+
# reads from the CLI finds the same deployment as the full SHA.
|
|
29
|
+
def apply_revision_filter(scope)
|
|
30
|
+
return scope unless params[:revision].present?
|
|
31
|
+
|
|
32
|
+
scope.where(
|
|
33
|
+
"revision LIKE :prefix #{RailsPulse::LikePattern::CLAUSE}",
|
|
34
|
+
prefix: RailsPulse::LikePattern.starting_with(params[:revision])
|
|
35
|
+
)
|
|
36
|
+
end
|
|
21
37
|
end
|
|
22
38
|
end
|
|
23
39
|
end
|
|
@@ -33,6 +33,7 @@ module RailsPulse
|
|
|
33
33
|
|
|
34
34
|
collection = RailsPulse::ExceptionGroup.order(sort => :desc, id: :desc)
|
|
35
35
|
collection = collection.where(status: status) if status
|
|
36
|
+
collection = collection.where(fingerprint: params[:fingerprint]) if params[:fingerprint].present?
|
|
36
37
|
collection = collection.where(last_seen_at: since_start..) if since_start
|
|
37
38
|
collection = collection.where(last_seen_at: ..until_end) if until_end
|
|
38
39
|
collection = apply_search(collection)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Api
|
|
3
|
+
module V1
|
|
4
|
+
# What needs attention over one summary period, and whether the
|
|
5
|
+
# configured route and query thresholds fit what that period recorded.
|
|
6
|
+
# One period rather than a since/until window because a P95 cannot be
|
|
7
|
+
# combined across periods: an hour, day, week or month is read from its
|
|
8
|
+
# own summary rows.
|
|
9
|
+
class InsightsController < BaseController
|
|
10
|
+
PERIOD_TYPES = RailsPulse::Summary::PERIOD_TYPES
|
|
11
|
+
DEFAULT_PERIOD_TYPE = "week".freeze
|
|
12
|
+
STEPS = { "hour" => 1.hour, "day" => 1.day, "week" => 1.week, "month" => 1.month }.freeze
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
period_type = params[:period].presence || DEFAULT_PERIOD_TYPE
|
|
16
|
+
unless PERIOD_TYPES.include?(period_type)
|
|
17
|
+
return render_bad_request("Unknown period '#{period_type}'. Use one of: #{PERIOD_TYPES.join(', ')}.")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
at = parse_time_param(:at)
|
|
21
|
+
return if performed?
|
|
22
|
+
|
|
23
|
+
period_start = period_start_for(period_type, at)
|
|
24
|
+
period_end = RailsPulse::Summary.calculate_period_end(period_type, period_start)
|
|
25
|
+
|
|
26
|
+
render json: {
|
|
27
|
+
period: {
|
|
28
|
+
type: period_type,
|
|
29
|
+
start: period_start,
|
|
30
|
+
end: period_end,
|
|
31
|
+
summarized: summarized?(period_type, period_start)
|
|
32
|
+
},
|
|
33
|
+
thresholds: {
|
|
34
|
+
routes: RailsPulse.configuration.route_thresholds,
|
|
35
|
+
queries: RailsPulse.configuration.query_thresholds,
|
|
36
|
+
jobs: RailsPulse.configuration.job_thresholds
|
|
37
|
+
},
|
|
38
|
+
needs_attention: RailsPulse::PeriodInsights.new(period_type: period_type, period_start: period_start).to_insights_data,
|
|
39
|
+
threshold_recommendations: RailsPulse::ConfigRecommendations
|
|
40
|
+
.for_period(period_type: period_type, period_start: period_start)
|
|
41
|
+
.to_recommendations
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The period containing `at`, or the latest one that has ended: the
|
|
48
|
+
# current period has no summary until it is over.
|
|
49
|
+
def period_start_for(period_type, at)
|
|
50
|
+
time = at ? at.in_time_zone : Time.current - STEPS[period_type]
|
|
51
|
+
RailsPulse::Summary.normalize_period_start(period_type, time)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# SummaryJob writes the overall request row for every period it
|
|
55
|
+
# summarizes, even an empty one.
|
|
56
|
+
def summarized?(period_type, period_start)
|
|
57
|
+
RailsPulse::Summary.overall_requests.exists?(period_type: period_type, period_start: period_start)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -20,16 +20,25 @@ module RailsPulse
|
|
|
20
20
|
since_start ||= 24.hours.ago if until_end.nil?
|
|
21
21
|
render_with_stats(since_start..until_end, sort || "total_duration")
|
|
22
22
|
else
|
|
23
|
-
data, meta = paginated(
|
|
23
|
+
data, meta = paginated(queries_scope.order(:id))
|
|
24
24
|
render json: { data: data.map { |query| QuerySerializer.serialize(query) }, meta: meta }
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
+
# `hashed_sql` is the key Rails Pulse Cloud holds for a query, so an
|
|
31
|
+
# exact match on it is how a caller gets from Cloud back to the SQL.
|
|
32
|
+
def queries_scope
|
|
33
|
+
scope = RailsPulse::Query.all
|
|
34
|
+
scope = scope.where(hashed_sql: params[:hashed_sql]) if params[:hashed_sql].present?
|
|
35
|
+
scope
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
def render_with_stats(range, sort)
|
|
31
39
|
base = RailsPulse::Operation.where.not(query_id: nil).where(occurred_at: range)
|
|
32
40
|
base = apply_route_filter(base)
|
|
41
|
+
base = base.where(query_id: queries_scope.select(:id)) if params[:hashed_sql].present?
|
|
33
42
|
total = base.distinct.count(:query_id)
|
|
34
43
|
|
|
35
44
|
rows = base
|
|
@@ -6,7 +6,7 @@ module RailsPulse
|
|
|
6
6
|
STATUS_CODE = /\A\d{3}\z/
|
|
7
7
|
|
|
8
8
|
def index
|
|
9
|
-
collection = RailsPulse::Request.
|
|
9
|
+
collection = RailsPulse::Request.preload(:route).order(occurred_at: :desc, id: :desc)
|
|
10
10
|
|
|
11
11
|
parsed_range = time_range
|
|
12
12
|
return unless parsed_range
|
|
@@ -15,6 +15,7 @@ module RailsPulse
|
|
|
15
15
|
collection = collection.where(occurred_at: since_start..) if since_start
|
|
16
16
|
collection = collection.where(occurred_at: ..until_end) if until_end
|
|
17
17
|
collection = apply_route_filter(collection)
|
|
18
|
+
collection = apply_path_prefix_filter(collection)
|
|
18
19
|
collection = apply_status_filter(collection)
|
|
19
20
|
return if performed?
|
|
20
21
|
|
|
@@ -38,6 +39,21 @@ module RailsPulse
|
|
|
38
39
|
)
|
|
39
40
|
end
|
|
40
41
|
|
|
42
|
+
# Requests whose route path starts with the prefix. A request the
|
|
43
|
+
# router did not recognise is stored under its raw path, so this is how
|
|
44
|
+
# a caller finds the paths behind a burst of 404s. A trailing `*` is
|
|
45
|
+
# dropped, so a grouped prefix such as `/wp-admin/*` can be passed as
|
|
46
|
+
# it is; `/*` and `*` therefore match every path.
|
|
47
|
+
def apply_path_prefix_filter(scope)
|
|
48
|
+
return scope unless params[:path_prefix].present?
|
|
49
|
+
|
|
50
|
+
prefix = params[:path_prefix].to_s.delete_suffix("*")
|
|
51
|
+
scope.joins(:route).where(
|
|
52
|
+
"rails_pulse_routes.path LIKE :prefix #{RailsPulse::LikePattern::CLAUSE}",
|
|
53
|
+
prefix: RailsPulse::LikePattern.starting_with(prefix)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
41
57
|
def apply_status_filter(scope)
|
|
42
58
|
return scope unless params[:status].present?
|
|
43
59
|
|
|
@@ -15,6 +15,11 @@ module RailsPulse
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
scope = RailsPulse::Route.all
|
|
18
|
+
# Exact matches, unlike `search`: a route is identified by its path
|
|
19
|
+
# and controller action together, and a substring would also return
|
|
20
|
+
# `/orders/:id/items` for `/orders/:id`.
|
|
21
|
+
scope = scope.where(path: params[:path]) if params[:path].present?
|
|
22
|
+
scope = scope.where(controller_action: params[:controller_action]) if params[:controller_action].present?
|
|
18
23
|
if params[:search].present?
|
|
19
24
|
term = RailsPulse::LikePattern.containing(params[:search].downcase)
|
|
20
25
|
scope = scope.where(
|
|
@@ -37,7 +42,7 @@ module RailsPulse
|
|
|
37
42
|
|
|
38
43
|
def render_with_stats(scope, range, sort)
|
|
39
44
|
base = RailsPulse::Request.where(occurred_at: range)
|
|
40
|
-
base = base.where(route_id: scope.select(:id)) if
|
|
45
|
+
base = base.where(route_id: scope.select(:id)) if %i[search path controller_action].any? { |name| params[name].present? }
|
|
41
46
|
|
|
42
47
|
# HAVING rather than a filter over the fetched page: a route busy
|
|
43
48
|
# enough to qualify can rank below the limit, and dropping it after
|
|
@@ -31,13 +31,17 @@ module RailsPulse
|
|
|
31
31
|
@response_time_percentiles_chart_data = RailsPulse::Dashboard::Charts::ResponseTimePercentiles.new(**card_and_chart_options).to_chart_data
|
|
32
32
|
@throughput_and_errors_chart_data = RailsPulse::Dashboard::Charts::ThroughputAndErrors.new(**card_and_chart_options).to_chart_data
|
|
33
33
|
|
|
34
|
+
# One StoragePressure serves the whole page: its items cost several
|
|
35
|
+
# aggregate queries, and three panels below ask for them.
|
|
36
|
+
storage_pressure = RailsPulse::Dashboard::StoragePressure.new
|
|
37
|
+
|
|
34
38
|
# Needs Attention panel
|
|
35
|
-
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_attention_data
|
|
39
|
+
@needs_attention = RailsPulse::Dashboard::NeedsAttention.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, period_type: period_type, window: window, storage_pressure: storage_pressure).to_attention_data
|
|
36
40
|
|
|
37
41
|
# System Health bar
|
|
38
|
-
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, window: window).to_health_data
|
|
42
|
+
@health_summary = RailsPulse::Dashboard::HealthSummary.new(disabled_tags: disabled_tags, show_non_tagged: show_non_tagged, period: period, period_type: period_type, window: window, storage_pressure: storage_pressure).to_health_data
|
|
39
43
|
|
|
40
|
-
@storage_status = RailsPulse::Dashboard::StorageStatus.new(
|
|
44
|
+
@storage_status = RailsPulse::Dashboard::StorageStatus.new(estimated: true, storage_pressure: storage_pressure)
|
|
41
45
|
|
|
42
46
|
# Deployments panel — scoped to the same window as the chart markers so
|
|
43
47
|
# the panel and the markers drawn on the charts always agree.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Sends the health update for the minute just ended to Rails Pulse Cloud,
|
|
3
|
+
# deployments recorded since the last run, and any buffered batches whose
|
|
4
|
+
# retry is due. Schedule it every minute alongside SummaryJob. Does nothing
|
|
5
|
+
# unless config.cloud is set.
|
|
6
|
+
class CloudHealthJob < ApplicationJob
|
|
7
|
+
def perform
|
|
8
|
+
Cloud::Sync.new.minutely
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# Sends newly summarized hours, changed exception groups and deployments to
|
|
3
|
+
# Rails Pulse Cloud. SummaryJob enqueues it once its summaries are written,
|
|
4
|
+
# so it needs no schedule of its own. Does nothing unless config.cloud is
|
|
5
|
+
# set.
|
|
6
|
+
class CloudSyncJob < ApplicationJob
|
|
7
|
+
def perform
|
|
8
|
+
Cloud::Sync.new.hourly
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -19,6 +19,8 @@ module RailsPulse
|
|
|
19
19
|
# Check if we should run monthly summary (first day of month)
|
|
20
20
|
process_monthly_summary((target_hour.to_date - 1.month).beginning_of_month) if first_of_month?(target_hour)
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
enqueue_cloud_sync
|
|
22
24
|
rescue StandardError => e
|
|
23
25
|
log_error(e)
|
|
24
26
|
raise
|
|
@@ -26,6 +28,17 @@ module RailsPulse
|
|
|
26
28
|
|
|
27
29
|
private
|
|
28
30
|
|
|
31
|
+
# Cloud receives an hour only once it is summarized, so the sync follows
|
|
32
|
+
# this job rather than running on a schedule of its own. A queue that
|
|
33
|
+
# refuses the job must not fail the summaries already written.
|
|
34
|
+
def enqueue_cloud_sync
|
|
35
|
+
return unless RailsPulse.configuration.cloud.enabled?
|
|
36
|
+
|
|
37
|
+
CloudSyncJob.perform_later
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
RailsPulse.logger.error "Could not enqueue the Rails Pulse Cloud sync: #{e.class} - #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
|
|
29
42
|
def midnight?(time)
|
|
30
43
|
time.hour == 0
|
|
31
44
|
end
|
|
@@ -24,23 +24,14 @@ module RailsPulse
|
|
|
24
24
|
.then { |q| @subject ? q.where(summarizable_id: @subject.id) : q }
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# Common helper for time step calculation
|
|
28
|
-
def time_step
|
|
29
|
-
@period_type.to_s == "hour" ? 3600 : 86400
|
|
30
|
-
end
|
|
31
|
-
|
|
32
27
|
# Abstract method - must be implemented by subclasses
|
|
33
28
|
def summarizable_type
|
|
34
29
|
raise NotImplementedError, "#{self.class} must implement summarizable_type"
|
|
35
30
|
end
|
|
36
31
|
|
|
37
|
-
#
|
|
38
|
-
def pad_data_with_zeros(raw_data
|
|
39
|
-
|
|
40
|
-
(start_time.to_i..end_time.to_i).step(step) do |timestamp|
|
|
41
|
-
padded[timestamp] = raw_data[timestamp] || 0
|
|
42
|
-
end
|
|
43
|
-
end
|
|
32
|
+
# Every bucket in the window, with zero where there is no data.
|
|
33
|
+
def pad_data_with_zeros(raw_data)
|
|
34
|
+
@window.bucket_timestamps(@period_type).index_with { |timestamp| raw_data[timestamp] || 0 }
|
|
44
35
|
end
|
|
45
36
|
end
|
|
46
37
|
end
|
|
@@ -47,9 +47,8 @@ module RailsPulse
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Convert to final values (weighted averages) and pad missing data
|
|
50
|
-
step = @period_type.to_s == "hour" ? 3600 : 86400
|
|
51
50
|
daily_data = {}
|
|
52
|
-
|
|
51
|
+
@window.bucket_timestamps(@period_type).each do |timestamp|
|
|
53
52
|
if raw_data[timestamp]
|
|
54
53
|
count = raw_data[timestamp][:total_count]
|
|
55
54
|
daily_data[timestamp] = {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
# One CleanupService run, stored as an Event of kind cleanup_run: `value`
|
|
3
|
+
# is the rows it deleted, `outcome` completed or failed, and `metadata` the
|
|
4
|
+
# per-table counts and any stages that failed. rails_pulse:status reads the
|
|
5
|
+
# latest to say whether cleanup is running at all; nothing else depends on
|
|
6
|
+
# these rows, so event retention prunes them like any other kind.
|
|
7
|
+
class CleanupRun
|
|
8
|
+
KIND = "cleanup_run".freeze
|
|
9
|
+
SUBJECT = "cleanup".freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def events
|
|
13
|
+
RailsPulse::Event.of_kind(KIND)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def record!(stats:, failed_stages: [], ran_at: Time.current)
|
|
17
|
+
RailsPulse::Event.create!(
|
|
18
|
+
kind: KIND,
|
|
19
|
+
subject: SUBJECT,
|
|
20
|
+
outcome: failed_stages.any? ? "failed" : "completed",
|
|
21
|
+
value: stats[:total_deleted].to_i,
|
|
22
|
+
occurred_at: ran_at,
|
|
23
|
+
metadata: { time_based: stats[:time_based], count_based: stats[:count_based], failed_stages: failed_stages }.to_json
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def latest
|
|
28
|
+
events.recent.first
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "zlib"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module Cloud
|
|
5
|
+
# A batch waiting to be sent to Rails Pulse Cloud, stored gzipped. It
|
|
6
|
+
# keeps its batch_id across retries, so Cloud recognises a resend. The
|
|
7
|
+
# buffer holds at most MAX_AGE or MAX_BYTES of batches, dropping the
|
|
8
|
+
# oldest first, so an outage cannot grow it without bound.
|
|
9
|
+
class BufferedBatch < RailsPulse::ApplicationRecord
|
|
10
|
+
self.table_name = "rails_pulse_cloud_batches"
|
|
11
|
+
|
|
12
|
+
MAX_AGE = 7.days
|
|
13
|
+
MAX_BYTES = 50.megabytes
|
|
14
|
+
|
|
15
|
+
validates :batch_id, :payload, :byte_size, :item_count, :next_attempt_at, presence: true
|
|
16
|
+
|
|
17
|
+
scope :due, ->(now = Time.current) { where(next_attempt_at: ..now).order(:created_at, :id) }
|
|
18
|
+
|
|
19
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
20
|
+
%w[batch_id byte_size item_count attempts next_attempt_at created_at]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.enqueue!(batch, now: Time.current)
|
|
28
|
+
payload = Zlib.gzip(batch.to_json)
|
|
29
|
+
create!(batch_id: batch.batch_id, payload: payload, byte_size: payload.bytesize, item_count: batch.items.size,
|
|
30
|
+
next_attempt_at: now)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Drops batches past MAX_AGE, then the oldest until the rest fit in
|
|
34
|
+
# MAX_BYTES. Returns how many were dropped.
|
|
35
|
+
def self.prune!(now = Time.current)
|
|
36
|
+
dropped = where(created_at: ...(now - MAX_AGE)).delete_all
|
|
37
|
+
total = sum(:byte_size)
|
|
38
|
+
return dropped if total <= MAX_BYTES
|
|
39
|
+
|
|
40
|
+
order(:created_at, :id).pluck(:id, :byte_size).each do |id, size|
|
|
41
|
+
break if total <= MAX_BYTES
|
|
42
|
+
|
|
43
|
+
dropped += where(id: id).delete_all
|
|
44
|
+
total -= size
|
|
45
|
+
end
|
|
46
|
+
dropped
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The stored batch as a hash with string keys.
|
|
50
|
+
def contents
|
|
51
|
+
JSON.parse(Zlib.gunzip(payload))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# The one row naming this Rails Pulse database to Rails Pulse Cloud, and
|
|
4
|
+
# where its sync has got to. Hosts that share the database share the row,
|
|
5
|
+
# so their data is counted once; hosts with separate databases are
|
|
6
|
+
# separate installations, and Cloud adds them together.
|
|
7
|
+
class Installation < RailsPulse::ApplicationRecord
|
|
8
|
+
self.table_name = "rails_pulse_cloud_installations"
|
|
9
|
+
|
|
10
|
+
validates :installation_id, presence: true
|
|
11
|
+
|
|
12
|
+
def self.ransackable_attributes(_auth_object = nil)
|
|
13
|
+
%w[installation_id last_success_at last_error_at paused_until created_at]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.ransackable_associations(_auth_object = nil)
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The row, created with a new installation ID the first time it is
|
|
21
|
+
# asked for. A second process creating it at the same moment loses on
|
|
22
|
+
# the unique index and reads the winner's.
|
|
23
|
+
def self.current
|
|
24
|
+
first || create!(installation_id: SecureRandom.uuid)
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
first!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The row if one exists, without creating it.
|
|
30
|
+
def self.existing
|
|
31
|
+
first
|
|
32
|
+
rescue ActiveRecord::ActiveRecordError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def paused?(now = Time.current)
|
|
37
|
+
paused_until.present? && paused_until > now
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record_success!(deprecated_on: nil, now: Time.current)
|
|
41
|
+
update!(last_success_at: now, last_error: nil, last_error_at: nil, paused_until: nil, pause_reason: nil,
|
|
42
|
+
contract_deprecated_on: deprecated_on)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def record_error!(message, now: Time.current)
|
|
46
|
+
update!(last_error: message.to_s.truncate(2_000), last_error_at: now)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def pause!(until_time, reason, now: Time.current)
|
|
50
|
+
update!(paused_until: until_time, pause_reason: reason.to_s.truncate(2_000),
|
|
51
|
+
last_error: reason.to_s.truncate(2_000), last_error_at: now)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Dashboard
|
|
3
|
+
module Concerns
|
|
4
|
+
# How a route, query or job earns a place in a needs-attention list,
|
|
5
|
+
# shared by the dashboard panel (NeedsAttention, over the selected
|
|
6
|
+
# window) and the API's PeriodInsights (over one summary period) so the
|
|
7
|
+
# two never disagree about what is critical.
|
|
8
|
+
#
|
|
9
|
+
# Each classify method returns nil for something healthy, otherwise
|
|
10
|
+
# [severity, reason, metric, metric_sub, sort_score]. The includer sets
|
|
11
|
+
# @route_thresholds, @query_thresholds and @job_thresholds.
|
|
12
|
+
module AttentionClassification
|
|
13
|
+
include ThresholdConstants
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# `span` finishes the error-rate reason: "this week", "this period".
|
|
18
|
+
def classify_route(p95, total, errors, error_rate, span)
|
|
19
|
+
if p95 >= @route_thresholds[:critical] || error_rate >= CRITICAL_ERROR_RATE
|
|
20
|
+
if error_rate >= CRITICAL_ERROR_RATE
|
|
21
|
+
[ :critical,
|
|
22
|
+
"#{error_rate}% error rate · #{total} requests #{span}",
|
|
23
|
+
"#{errors} errors",
|
|
24
|
+
"P95 #{p95.round(0).to_i}ms",
|
|
25
|
+
errors * total.to_f ]
|
|
26
|
+
else
|
|
27
|
+
[ :critical,
|
|
28
|
+
"#{p95.round(0).to_i}ms P95 · exceeds #{@route_thresholds[:critical]}ms threshold",
|
|
29
|
+
"#{p95.round(0).to_i}ms P95",
|
|
30
|
+
"#{total} requests",
|
|
31
|
+
p95 ]
|
|
32
|
+
end
|
|
33
|
+
elsif p95 >= @route_thresholds[:slow] || error_rate >= WARNING_ERROR_RATE
|
|
34
|
+
if error_rate >= WARNING_ERROR_RATE && p95 < @route_thresholds[:slow]
|
|
35
|
+
[ :warning,
|
|
36
|
+
"#{error_rate}% error rate · #{total} requests #{span}",
|
|
37
|
+
"#{errors} errors",
|
|
38
|
+
"P95 #{p95.round(0).to_i}ms",
|
|
39
|
+
errors * total.to_f ]
|
|
40
|
+
else
|
|
41
|
+
[ :warning,
|
|
42
|
+
"#{p95.round(0).to_i}ms P95 · #{error_rate > 0 ? "#{error_rate}% error rate" : "above slow threshold"}",
|
|
43
|
+
"#{p95.round(0).to_i}ms P95",
|
|
44
|
+
"#{total} requests",
|
|
45
|
+
p95 ]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def classify_query(p95, count)
|
|
51
|
+
if p95 >= @query_thresholds[:critical]
|
|
52
|
+
severity = :critical
|
|
53
|
+
reason = "#{p95.round(0).to_i}ms P95 · exceeds #{@query_thresholds[:critical]}ms threshold"
|
|
54
|
+
elsif p95 >= @query_thresholds[:slow]
|
|
55
|
+
severity = :warning
|
|
56
|
+
reason = "#{p95.round(0).to_i}ms P95 · above #{@query_thresholds[:slow]}ms slow threshold"
|
|
57
|
+
else
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
[ severity, reason, "#{p95.round(0).to_i}ms P95", "#{count} execution#{count == 1 ? "" : "s"}", p95 ]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def classify_job(failure_rate, p95, runs, failures, queue)
|
|
65
|
+
if failure_rate >= CRITICAL_JOB_FAILURE_RATE || p95 >= @job_thresholds[:critical]
|
|
66
|
+
if failure_rate >= CRITICAL_JOB_FAILURE_RATE
|
|
67
|
+
[ :critical,
|
|
68
|
+
"#{queue} queue · #{failure_rate}% failure rate",
|
|
69
|
+
"#{failures} / #{runs} failed",
|
|
70
|
+
"P95 #{p95.round(0).to_i}ms",
|
|
71
|
+
failures * runs.to_f ]
|
|
72
|
+
else
|
|
73
|
+
[ :critical,
|
|
74
|
+
"P95 #{p95.round(0).to_i}ms · exceeds #{@job_thresholds[:critical]}ms threshold",
|
|
75
|
+
"#{p95.round(0).to_i}ms P95",
|
|
76
|
+
"#{runs} runs",
|
|
77
|
+
p95 ]
|
|
78
|
+
end
|
|
79
|
+
elsif failure_rate >= WARNING_JOB_FAILURE_RATE || p95 >= @job_thresholds[:slow]
|
|
80
|
+
if failure_rate >= WARNING_JOB_FAILURE_RATE
|
|
81
|
+
[ :warning,
|
|
82
|
+
"#{queue} queue · #{failure_rate}% failure rate",
|
|
83
|
+
"#{failures} / #{runs} failed",
|
|
84
|
+
"P95 #{p95.round(0).to_i}ms",
|
|
85
|
+
failures * runs.to_f ]
|
|
86
|
+
else
|
|
87
|
+
[ :warning,
|
|
88
|
+
"P95 #{p95.round(0).to_i}ms · above slow threshold",
|
|
89
|
+
"#{p95.round(0).to_i}ms P95",
|
|
90
|
+
"#{runs} runs",
|
|
91
|
+
p95 ]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def truncate_sql(sql)
|
|
97
|
+
return "" if sql.blank?
|
|
98
|
+
cleaned = sql.gsub(/\s+/, " ").strip
|
|
99
|
+
cleaned.length > 80 ? "#{cleaned[0..79]}..." : cleaned
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|