rails_pulse 0.4.2 → 0.5.0.pre.1
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 +33 -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 +130 -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 +24 -0
- data/app/controllers/rails_pulse/api/v1/exceptions_controller.rb +76 -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 +114 -0
- data/app/controllers/rails_pulse/api/v1/requests_controller.rb +58 -0
- data/app/controllers/rails_pulse/api/v1/routes_controller.rb +98 -0
- data/app/controllers/rails_pulse/application_controller.rb +1 -1
- data/app/controllers/rails_pulse/dashboard_controller.rb +20 -20
- 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/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 +2 -6
- data/app/models/rails_pulse/charts/percentile_chart_base.rb +3 -4
- 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/time_range_helper.rb +1 -2
- data/app/models/rails_pulse/dashboard/health_summary.rb +30 -3
- data/app/models/rails_pulse/dashboard/needs_attention.rb +2 -3
- data/app/models/rails_pulse/dashboard/storage_pressure.rb +34 -4
- data/app/models/rails_pulse/dashboard/storage_status.rb +36 -0
- data/app/models/rails_pulse/deployment.rb +2 -7
- data/app/models/rails_pulse/event.rb +40 -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 -5
- 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 -1
- data/app/models/rails_pulse/jobs/charts/execution_volume.rb +1 -1
- data/app/models/rails_pulse/jobs/charts/failure_rate.rb +1 -1
- data/app/models/rails_pulse/like_pattern.rb +29 -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 -6
- 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/writer_heartbeat.rb +86 -0
- data/app/serializers/rails_pulse/api/v1/deployment_serializer.rb +20 -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 +21 -0
- data/app/serializers/rails_pulse/api/v1/route_serializer.rb +19 -0
- data/app/services/rails_pulse/exception_capture_service.rb +1 -1
- data/app/services/rails_pulse/operations/comparison.rb +1 -1
- 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/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 +37 -0
- data/config/routes.rb +19 -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 +1 -0
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +20 -2
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +36 -13
- data/lib/rails_pulse/cleanup_service.rb +26 -20
- data/lib/rails_pulse/cli/agent_files/agents.md +87 -0
- data/lib/rails_pulse/cli/agent_files/claude_skill.md +165 -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 +40 -0
- data/lib/rails_pulse/cli/exceptions.rb +111 -0
- data/lib/rails_pulse/cli/formatter.rb +31 -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 +72 -0
- data/lib/rails_pulse/cli/mcp.rb +47 -0
- data/lib/rails_pulse/cli/queries.rb +62 -0
- data/lib/rails_pulse/cli/requests.rb +49 -0
- data/lib/rails_pulse/cli/routes.rb +66 -0
- data/lib/rails_pulse/configuration.rb +23 -1
- data/lib/rails_pulse/current.rb +11 -0
- data/lib/rails_pulse/engine.rb +12 -3
- data/lib/rails_pulse/job_run_collector.rb +15 -15
- data/lib/rails_pulse/mcp/server.rb +76 -0
- data/lib/rails_pulse/mcp/tools/coverage.rb +96 -0
- data/lib/rails_pulse/mcp/tools/deployments.rb +80 -0
- data/lib/rails_pulse/mcp/tools/endpoint.rb +167 -0
- data/lib/rails_pulse/mcp/tools/errors.rb +95 -0
- data/lib/rails_pulse/mcp/tools/exception_detail.rb +118 -0
- data/lib/rails_pulse/mcp/tools/exceptions.rb +114 -0
- data/lib/rails_pulse/mcp/tools/helpers.rb +162 -0
- data/lib/rails_pulse/mcp/tools/jobs.rb +155 -0
- data/lib/rails_pulse/mcp/tools/queries.rb +136 -0
- data/lib/rails_pulse/mcp/tools/routes.rb +97 -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 +26 -0
- data/lib/rails_pulse/tracker.rb +75 -14
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +0 -4
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +13 -13
- metadata +96 -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,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,21 @@
|
|
|
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
|
+
occurred_at: request.occurred_at,
|
|
10
|
+
duration: request.duration,
|
|
11
|
+
status: request.status,
|
|
12
|
+
is_error: request.is_error,
|
|
13
|
+
request_uuid: request.request_uuid,
|
|
14
|
+
controller_action: request.controller_action,
|
|
15
|
+
response_size_bytes: request.response_size_bytes
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
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
|
|
@@ -55,7 +55,7 @@ module RailsPulse
|
|
|
55
55
|
|
|
56
56
|
def capture
|
|
57
57
|
return unless RailsPulse.configuration.track_exceptions
|
|
58
|
-
return if
|
|
58
|
+
return if RailsPulse::Current.skip_recording_rails_pulse_activity
|
|
59
59
|
|
|
60
60
|
frames = parse_backtrace(@exception.backtrace || [])
|
|
61
61
|
location = fingerprint_location(frames)
|
|
@@ -3,7 +3,7 @@ module RailsPulse
|
|
|
3
3
|
# The result of measuring a recent window against a historical baseline.
|
|
4
4
|
#
|
|
5
5
|
# This is a value object on purpose. It is the shape callers outside this
|
|
6
|
-
# namespace — dashboards today, findings and
|
|
6
|
+
# namespace — dashboards today, findings and agent tooling later — are expected
|
|
7
7
|
# to depend on, so it carries every number needed to explain the verdict
|
|
8
8
|
# rather than just the verdict.
|
|
9
9
|
class Comparison
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class SummaryService
|
|
3
|
+
# Summary rows for a day, week or month, rolled up from the summaries of
|
|
4
|
+
# its child periods (hours for a day, days for a week or month) without
|
|
5
|
+
# reading raw rows.
|
|
6
|
+
#
|
|
7
|
+
# The database combines the children in one GROUP BY, so Ruby receives
|
|
8
|
+
# one row per summarizable however many child periods there are. Counts,
|
|
9
|
+
# totals, averages, min, max, status counts and the standard deviation
|
|
10
|
+
# combine exactly. P50/P95/P99 are the count-weighted average of the
|
|
11
|
+
# children's, the same weighting Tables::Base applies when a dashboard
|
|
12
|
+
# range spans several periods.
|
|
13
|
+
#
|
|
14
|
+
# Only plain SUM, MIN, MAX and arithmetic are used, so the query runs
|
|
15
|
+
# unchanged on SQLite (which has no STDDEV or, without its math
|
|
16
|
+
# extension, POWER), PostgreSQL and MySQL. Divisions happen in Ruby,
|
|
17
|
+
# where integer sums cannot truncate them.
|
|
18
|
+
#
|
|
19
|
+
# Rows carry the summarizable and its metrics; SummaryService adds the
|
|
20
|
+
# period columns.
|
|
21
|
+
class FromChildPeriods
|
|
22
|
+
TABLE = "rails_pulse_summaries".freeze
|
|
23
|
+
PERCENTILES = %i[p50_duration p95_duration p99_duration].freeze
|
|
24
|
+
STATUS_COLUMNS = %i[error_count success_count status_2xx status_3xx status_4xx status_5xx].freeze
|
|
25
|
+
|
|
26
|
+
AGGREGATES = {
|
|
27
|
+
count: "SUM(#{TABLE}.count)",
|
|
28
|
+
total_duration: "SUM(#{TABLE}.total_duration)",
|
|
29
|
+
min_duration: "MIN(#{TABLE}.min_duration)",
|
|
30
|
+
max_duration: "MAX(#{TABLE}.max_duration)",
|
|
31
|
+
**PERCENTILES.flat_map { |column|
|
|
32
|
+
[
|
|
33
|
+
[ :"#{column}_weighted", "SUM(#{TABLE}.#{column} * #{TABLE}.count)" ],
|
|
34
|
+
[ :"#{column}_weight", "SUM(CASE WHEN #{TABLE}.#{column} IS NOT NULL THEN #{TABLE}.count ELSE 0 END)" ]
|
|
35
|
+
]
|
|
36
|
+
}.to_h,
|
|
37
|
+
# The three sums Statistics.pooled_stddev needs.
|
|
38
|
+
stddev_within_sum: "SUM((#{TABLE}.count - 1) * COALESCE(#{TABLE}.stddev_duration, 0) * COALESCE(#{TABLE}.stddev_duration, 0))",
|
|
39
|
+
weighted_mean_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration)",
|
|
40
|
+
weighted_square_sum: "SUM(#{TABLE}.count * #{TABLE}.avg_duration * #{TABLE}.avg_duration)",
|
|
41
|
+
**STATUS_COLUMNS.to_h { |column| [ column, "SUM(#{TABLE}.#{column})" ] }
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# `child_starts` are the boundaries the children must start on. Rows in
|
|
45
|
+
# the range that start elsewhere were written under a different
|
|
46
|
+
# aggregation time zone; they overlap these and would be counted twice.
|
|
47
|
+
def initialize(child_period_type, child_starts)
|
|
48
|
+
@child_period_type = child_period_type
|
|
49
|
+
@child_starts = child_starts
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Always exactly one row, even for an empty period (see SummaryService).
|
|
53
|
+
def request_rows
|
|
54
|
+
combined = combined_of("RailsPulse::Request")[0]
|
|
55
|
+
|
|
56
|
+
[ row("RailsPulse::Request", 0).merge(duration_metrics(combined), status_metrics(combined)) ]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def route_rows
|
|
60
|
+
combined_of("RailsPulse::Route").map do |route_id, combined|
|
|
61
|
+
row("RailsPulse::Route", route_id).merge(duration_metrics(combined), status_metrics(combined))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def query_rows
|
|
66
|
+
combined_of("RailsPulse::Query").map do |query_id, combined|
|
|
67
|
+
row("RailsPulse::Query", query_id).merge(duration_metrics(combined))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def job_rows
|
|
72
|
+
combined_by_job = combined_of("RailsPulse::Job")
|
|
73
|
+
return [] if combined_by_job.empty?
|
|
74
|
+
|
|
75
|
+
# A job whose row has gone (count-based cleanup) has nothing to
|
|
76
|
+
# summarize against.
|
|
77
|
+
known_job_ids = Job.where(id: combined_by_job.keys).pluck(:id).to_set
|
|
78
|
+
|
|
79
|
+
combined_by_job.filter_map do |job_id, combined|
|
|
80
|
+
next unless known_job_ids.include?(job_id)
|
|
81
|
+
|
|
82
|
+
row("RailsPulse::Job", job_id).merge(
|
|
83
|
+
duration_metrics(combined),
|
|
84
|
+
status_metrics(combined).slice(:error_count, :success_count)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The children already carry the all-groups row (id 0), which sums like
|
|
90
|
+
# any other group.
|
|
91
|
+
def exception_rows
|
|
92
|
+
combined_of("RailsPulse::ExceptionGroup").map do |group_id, combined|
|
|
93
|
+
row("RailsPulse::ExceptionGroup", group_id).merge(count: combined[:count].to_i)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def row(summarizable_type, summarizable_id)
|
|
100
|
+
{ summarizable_type: summarizable_type, summarizable_id: summarizable_id }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# { summarizable_type => { summarizable_id => { aggregate => value } } }.
|
|
104
|
+
# Empty children (an idle hour's count-0 heartbeat) are left out; they
|
|
105
|
+
# add nothing, and an idle period falls back to empty metrics.
|
|
106
|
+
def combined_children
|
|
107
|
+
@combined_children ||= Summary
|
|
108
|
+
.where(period_type: @child_period_type, period_start: @child_starts)
|
|
109
|
+
.where(count: 1..)
|
|
110
|
+
.group(:summarizable_type, :summarizable_id)
|
|
111
|
+
.pluck(:summarizable_type, :summarizable_id, *AGGREGATES.values.map { |sql| Arel.sql(sql) })
|
|
112
|
+
.each_with_object({}) do |(type, id, *values), by_type|
|
|
113
|
+
(by_type[type] ||= {})[id] = AGGREGATES.keys.zip(values).to_h
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# { summarizable_id => combined } for one summarizable type.
|
|
118
|
+
def combined_of(summarizable_type)
|
|
119
|
+
combined_children.fetch(summarizable_type, {})
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def duration_metrics(combined)
|
|
123
|
+
return Metrics.duration([]) unless combined
|
|
124
|
+
|
|
125
|
+
count = combined[:count].to_i
|
|
126
|
+
total = combined[:total_duration].to_f
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
count: count,
|
|
130
|
+
# Count-weighted mean of the children's averages: identical to
|
|
131
|
+
# total / count where every row has a duration (requests, routes,
|
|
132
|
+
# queries), and consistent with the children for jobs, whose count
|
|
133
|
+
# includes runs with no recorded duration (discarded runs) that
|
|
134
|
+
# total / count would dilute the average with.
|
|
135
|
+
avg_duration: combined[:weighted_mean_sum].to_f / count,
|
|
136
|
+
min_duration: combined[:min_duration]&.to_f,
|
|
137
|
+
max_duration: combined[:max_duration]&.to_f,
|
|
138
|
+
total_duration: total,
|
|
139
|
+
**PERCENTILES.to_h { |column| [ column, weighted_percentile(combined, column) ] },
|
|
140
|
+
stddev_duration: Statistics.pooled_stddev(
|
|
141
|
+
count: count,
|
|
142
|
+
within_sum: combined[:stddev_within_sum],
|
|
143
|
+
weighted_mean_sum: combined[:weighted_mean_sum],
|
|
144
|
+
weighted_square_sum: combined[:weighted_square_sum]
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def weighted_percentile(combined, column)
|
|
150
|
+
weight = combined[:"#{column}_weight"].to_f
|
|
151
|
+
return nil if weight.zero?
|
|
152
|
+
|
|
153
|
+
combined[:"#{column}_weighted"].to_f / weight
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def status_metrics(combined)
|
|
157
|
+
STATUS_COLUMNS.index_with { |column| combined ? combined[column].to_i : 0 }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class SummaryService
|
|
3
|
+
# Summary rows for an hour, computed from raw requests, operations, job
|
|
4
|
+
# runs and exception occurrences.
|
|
5
|
+
#
|
|
6
|
+
# Each kind is read with one query, sorted by summarizable and then
|
|
7
|
+
# duration so every summarizable's durations arrive as one consecutive,
|
|
8
|
+
# already-sorted run; the overall request row and the per-route rows
|
|
9
|
+
# share a single read. Memory is bounded by one hour's rows, and the
|
|
10
|
+
# hour costs one query per kind however many routes, queries or jobs it
|
|
11
|
+
# saw. Percentiles are exact.
|
|
12
|
+
#
|
|
13
|
+
# Rows carry the summarizable and its metrics; SummaryService adds the
|
|
14
|
+
# period columns.
|
|
15
|
+
class FromRawRows
|
|
16
|
+
def initialize(time_range)
|
|
17
|
+
@time_range = time_range
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Always exactly one row, even for an empty hour (see SummaryService).
|
|
21
|
+
def request_rows
|
|
22
|
+
durations = request_data.map { |_, duration, _| duration }.compact.sort
|
|
23
|
+
statuses = request_data.map { |_, _, status| status }
|
|
24
|
+
|
|
25
|
+
[ row("RailsPulse::Request", 0).merge(Metrics.duration(durations), Metrics.status(statuses)) ]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def route_rows
|
|
29
|
+
request_data.slice_when { |previous, current| previous.first != current.first }.map do |rows|
|
|
30
|
+
durations = rows.map { |_, duration, _| duration }
|
|
31
|
+
statuses = rows.map { |_, _, status| status }
|
|
32
|
+
|
|
33
|
+
row("RailsPulse::Route", rows.first.first).merge(
|
|
34
|
+
Metrics.duration(durations),
|
|
35
|
+
Metrics.status(statuses),
|
|
36
|
+
count: rows.size
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def query_rows
|
|
42
|
+
rows_by_summarizable(Operation.where(occurred_at: @time_range), :query_id).filter_map do |query_id, rows|
|
|
43
|
+
durations = rows.map(&:first).compact
|
|
44
|
+
next if durations.empty?
|
|
45
|
+
|
|
46
|
+
row("RailsPulse::Query", query_id).merge(Metrics.duration(durations))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def job_rows
|
|
51
|
+
scope = JobRun.where(occurred_at: @time_range).where(status: JobRun::FINAL_STATUSES)
|
|
52
|
+
runs_by_job = rows_by_summarizable(scope, :job_id, :status)
|
|
53
|
+
return [] if runs_by_job.empty?
|
|
54
|
+
|
|
55
|
+
# A run whose job row has gone (count-based cleanup) has nothing to
|
|
56
|
+
# summarize against.
|
|
57
|
+
known_job_ids = Job.where(id: runs_by_job.map(&:first)).pluck(:id).to_set
|
|
58
|
+
|
|
59
|
+
runs_by_job.filter_map do |job_id, runs|
|
|
60
|
+
next unless known_job_ids.include?(job_id)
|
|
61
|
+
|
|
62
|
+
durations = runs.map(&:first).compact.map(&:to_f)
|
|
63
|
+
next if durations.empty?
|
|
64
|
+
|
|
65
|
+
statuses = runs.map(&:second)
|
|
66
|
+
row("RailsPulse::Job", job_id).merge(
|
|
67
|
+
Metrics.duration(durations),
|
|
68
|
+
count: runs.size,
|
|
69
|
+
error_count: statuses.count { |s| s != "success" },
|
|
70
|
+
success_count: statuses.count { |s| s == "success" }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Per-group counts plus an all-groups row (id 0), so the dashboard can
|
|
76
|
+
# chart total exception volume without loading one series per group.
|
|
77
|
+
def exception_rows
|
|
78
|
+
counts = ExceptionOccurrence.where(occurred_at: @time_range).group(:exception_group_id).count
|
|
79
|
+
return [] if counts.empty?
|
|
80
|
+
|
|
81
|
+
rows = counts.map { |group_id, occurrences| row("RailsPulse::ExceptionGroup", group_id).merge(count: occurrences) }
|
|
82
|
+
rows << row("RailsPulse::ExceptionGroup", 0).merge(count: counts.values.sum)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def row(summarizable_type, summarizable_id)
|
|
88
|
+
{ summarizable_type: summarizable_type, summarizable_id: summarizable_id }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# One read serves the overall request row and every route's. route_id
|
|
92
|
+
# and duration are NOT NULL on requests, so ordering by (route_id,
|
|
93
|
+
# duration) hands each route its durations already sorted.
|
|
94
|
+
def request_data
|
|
95
|
+
@request_data ||= Request
|
|
96
|
+
.where(occurred_at: @time_range)
|
|
97
|
+
.order(:route_id, :duration)
|
|
98
|
+
.pluck(:route_id, :duration, :status)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# [[summarizable_id, [[duration, *extra_columns], ...]], ...], with
|
|
102
|
+
# each summarizable's rows in ascending duration order (NULL durations
|
|
103
|
+
# land at one end or the other depending on the database; callers
|
|
104
|
+
# compact them).
|
|
105
|
+
def rows_by_summarizable(scope, column, *extra_columns)
|
|
106
|
+
scope
|
|
107
|
+
.where.not(column => nil)
|
|
108
|
+
.order(column, :duration)
|
|
109
|
+
.pluck(column, :duration, *extra_columns)
|
|
110
|
+
.slice_when { |previous, current| previous.first != current.first }
|
|
111
|
+
.map { |rows| [ rows.first.first, rows.map { |row| row.drop(1) } ] }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class SummaryService
|
|
3
|
+
# The duration and status columns of a summary row, computed from raw
|
|
4
|
+
# values.
|
|
5
|
+
module Metrics
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# `durations` must be sorted. Empty input yields count 0 and nil
|
|
9
|
+
# percentiles, which is what an idle period should record.
|
|
10
|
+
def duration(durations)
|
|
11
|
+
avg = durations.any? ? durations.sum.to_f / durations.size : 0
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
count: durations.size,
|
|
15
|
+
avg_duration: avg,
|
|
16
|
+
min_duration: durations.first,
|
|
17
|
+
max_duration: durations.last,
|
|
18
|
+
total_duration: durations.sum,
|
|
19
|
+
p50_duration: Statistics.calculate_percentile(durations, 0.5),
|
|
20
|
+
p95_duration: Statistics.calculate_percentile(durations, 0.95),
|
|
21
|
+
p99_duration: Statistics.calculate_percentile(durations, 0.99),
|
|
22
|
+
stddev_duration: Statistics.calculate_stddev(durations, avg)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def status(statuses)
|
|
27
|
+
{
|
|
28
|
+
error_count: statuses.count { |s| s >= 500 },
|
|
29
|
+
success_count: statuses.count { |s| s < 500 },
|
|
30
|
+
status_2xx: statuses.count { |s| s.between?(200, 299) },
|
|
31
|
+
status_3xx: statuses.count { |s| s.between?(300, 399) },
|
|
32
|
+
status_4xx: statuses.count { |s| s.between?(400, 499) },
|
|
33
|
+
status_5xx: statuses.count { |s| s >= 500 }
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|