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,83 @@
|
|
|
1
|
+
require "zlib"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module RailsPulse
|
|
5
|
+
module Cloud
|
|
6
|
+
# Items wrapped in the envelope the sync contract defines, split so no
|
|
7
|
+
# batch exceeds what Cloud accepts. Each batch gets its own `batch_id`
|
|
8
|
+
# when it is built; a retry resends the same one, which is how Cloud
|
|
9
|
+
# tells a resend from new data.
|
|
10
|
+
class Batch
|
|
11
|
+
CONTRACT = 1
|
|
12
|
+
MAX_ITEMS = 5_000
|
|
13
|
+
MAX_COMPRESSED_BYTES = 1_000_000
|
|
14
|
+
|
|
15
|
+
attr_reader :envelope, :items, :batch_id
|
|
16
|
+
|
|
17
|
+
# @param items [Array<Hash>]
|
|
18
|
+
# @param envelope [Hash] the fields every batch repeats (see .envelope)
|
|
19
|
+
# @return [Array<Batch>] one or more batches, none over the limits
|
|
20
|
+
def self.build(items, envelope:)
|
|
21
|
+
items.each_slice(MAX_ITEMS).flat_map { |slice| fitting(slice, envelope) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Halves a batch until each part compresses under the limit. A single
|
|
25
|
+
# item larger than the limit is left as a batch of one; Cloud refuses
|
|
26
|
+
# it, which is the right outcome for an item that cannot be sent.
|
|
27
|
+
def self.fitting(items, envelope)
|
|
28
|
+
batch = new(items, envelope: envelope)
|
|
29
|
+
return [ batch ] if items.size <= 1 || batch.compressed_bytesize <= MAX_COMPRESSED_BYTES
|
|
30
|
+
|
|
31
|
+
middle = items.size / 2
|
|
32
|
+
fitting(items.first(middle), envelope) + fitting(items.drop(middle), envelope)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The envelope fields that identify where a batch came from.
|
|
36
|
+
def self.envelope(application:, environment:, installation_id:)
|
|
37
|
+
{
|
|
38
|
+
contract: CONTRACT,
|
|
39
|
+
application: application,
|
|
40
|
+
environment: environment,
|
|
41
|
+
installation_id: installation_id,
|
|
42
|
+
time_zone: Time.zone.tzinfo.name,
|
|
43
|
+
gem_version: RailsPulse::VERSION,
|
|
44
|
+
rails_version: Rails.version,
|
|
45
|
+
ruby_version: RUBY_VERSION,
|
|
46
|
+
database_adapter: RailsPulse::ApplicationRecord.connection_db_config.adapter.to_s
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A UUID version 7: a millisecond timestamp followed by random bits, so
|
|
51
|
+
# batch IDs sort by when they were built.
|
|
52
|
+
def self.uuid_v7(time = Time.current)
|
|
53
|
+
return SecureRandom.uuid_v7 if SecureRandom.respond_to?(:uuid_v7)
|
|
54
|
+
|
|
55
|
+
milliseconds = (time.to_r * 1000).to_i
|
|
56
|
+
bytes = [ milliseconds >> 16, milliseconds & 0xffff ].pack("Nn").bytes + SecureRandom.random_bytes(10).bytes
|
|
57
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70
|
|
58
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
59
|
+
hex = bytes.pack("C*").unpack1("H*")
|
|
60
|
+
[ hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12] ].join("-")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(items, envelope:, batch_id: self.class.uuid_v7, sent_at: Time.current)
|
|
64
|
+
@items = items
|
|
65
|
+
@envelope = envelope
|
|
66
|
+
@batch_id = batch_id
|
|
67
|
+
@sent_at = sent_at
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
{ contract: CONTRACT, batch_id: @batch_id, sent_at: @sent_at.utc.iso8601, **@envelope.except(:contract), items: @items }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_json(*)
|
|
75
|
+
to_h.to_json
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def compressed_bytesize
|
|
79
|
+
Zlib.gzip(to_json).bytesize
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "zlib"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RailsPulse
|
|
6
|
+
module Cloud
|
|
7
|
+
# Posts one batch to Rails Pulse Cloud. Gzipped JSON, the contract
|
|
8
|
+
# version in a header, and short timeouts: it runs in a background job,
|
|
9
|
+
# but a hung connection would still hold a worker.
|
|
10
|
+
class Client
|
|
11
|
+
OPEN_TIMEOUT = 5
|
|
12
|
+
READ_TIMEOUT = 15
|
|
13
|
+
PATH = "/v1/batches".freeze
|
|
14
|
+
|
|
15
|
+
Response = Struct.new(:status, :body, :headers, keyword_init: true) do
|
|
16
|
+
# The JSON error body Cloud sends, or an empty hash.
|
|
17
|
+
def error
|
|
18
|
+
parsed = JSON.parse(body.to_s)
|
|
19
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
20
|
+
rescue JSON::ParserError
|
|
21
|
+
{}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def message
|
|
25
|
+
error["message"].presence || "HTTP #{status}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def header(name)
|
|
29
|
+
headers[name.downcase]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raised when Cloud could not be reached or did not answer in time.
|
|
34
|
+
class Unavailable < StandardError; end
|
|
35
|
+
|
|
36
|
+
def initialize(settings = RailsPulse.configuration.cloud)
|
|
37
|
+
@settings = settings
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param body [String] the batch as JSON
|
|
41
|
+
# @return [Response]
|
|
42
|
+
def post(body)
|
|
43
|
+
uri = URI.join(@settings.url.to_s.chomp("/") + "/", PATH.delete_prefix("/"))
|
|
44
|
+
request = Net::HTTP::Post.new(uri)
|
|
45
|
+
request["Authorization"] = "Bearer #{@settings.api_key}"
|
|
46
|
+
request["Content-Type"] = "application/json"
|
|
47
|
+
request["Content-Encoding"] = "gzip"
|
|
48
|
+
request["Rails-Pulse-Contract"] = Batch::CONTRACT.to_s
|
|
49
|
+
request["User-Agent"] = user_agent
|
|
50
|
+
request.body = Zlib.gzip(body)
|
|
51
|
+
|
|
52
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
53
|
+
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT, write_timeout: READ_TIMEOUT) do |http|
|
|
54
|
+
http.request(request)
|
|
55
|
+
end
|
|
56
|
+
Response.new(status: response.code.to_i, body: response.body, headers: response.each_header.to_h)
|
|
57
|
+
rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse, EOFError => e
|
|
58
|
+
raise Unavailable, "#{e.class}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def user_agent
|
|
64
|
+
"rails_pulse/#{RailsPulse::VERSION} (ruby #{RUBY_VERSION}; rails #{Rails.version})"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# Several summary rows of one hour folded into one item's metrics: the
|
|
4
|
+
# unmatched paths under one prefix, the routes or queries past the
|
|
5
|
+
# per-hour cap, or two stored routes that share a pattern. Counts, totals
|
|
6
|
+
# and status counts add up exactly, as do min, max, the mean and the
|
|
7
|
+
# standard deviation; percentiles are the count-weighted average of the
|
|
8
|
+
# rows', as SummaryService does when it rolls hours up into a day.
|
|
9
|
+
module CombinedMetrics
|
|
10
|
+
PERCENTILES = %i[p50_duration p95_duration p99_duration].freeze
|
|
11
|
+
COUNTS = %i[error_count success_count status_2xx status_3xx status_4xx status_5xx].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param rows [Array<#[]>] summary rows, or hashes with the same keys
|
|
16
|
+
# @return [Hash] the metric fields of a summary item
|
|
17
|
+
def of(rows, counts: COUNTS)
|
|
18
|
+
rows = rows.reject { |row| row[:count].to_i.zero? }
|
|
19
|
+
count = rows.sum { |row| row[:count].to_i }
|
|
20
|
+
return { count: 0 } if count.zero?
|
|
21
|
+
|
|
22
|
+
weighted_mean_sum = rows.sum { |row| row[:count].to_i * row[:avg_duration].to_f }
|
|
23
|
+
{
|
|
24
|
+
count: count,
|
|
25
|
+
avg_duration: weighted_mean_sum / count,
|
|
26
|
+
min_duration: rows.filter_map { |row| row[:min_duration]&.to_f }.min,
|
|
27
|
+
max_duration: rows.filter_map { |row| row[:max_duration]&.to_f }.max,
|
|
28
|
+
total_duration: rows.sum { |row| row[:total_duration].to_f },
|
|
29
|
+
**PERCENTILES.index_with { |column| weighted(rows, column) },
|
|
30
|
+
stddev_duration: Statistics.pooled_stddev(
|
|
31
|
+
count: count,
|
|
32
|
+
within_sum: rows.sum { |row| (row[:count].to_i - 1) * (row[:stddev_duration].to_f**2) },
|
|
33
|
+
weighted_mean_sum: weighted_mean_sum,
|
|
34
|
+
weighted_square_sum: rows.sum { |row| row[:count].to_i * (row[:avg_duration].to_f**2) }
|
|
35
|
+
),
|
|
36
|
+
**counts.index_with { |column| rows.sum { |row| row[column].to_i } }
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def weighted(rows, column)
|
|
41
|
+
present = rows.reject { |row| row[column].nil? }
|
|
42
|
+
weight = present.sum { |row| row[:count].to_i }
|
|
43
|
+
return nil if weight.zero?
|
|
44
|
+
|
|
45
|
+
present.sum { |row| row[column].to_f * row[:count].to_i } / weight
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# The `health` item for one minute: request volume, errors and response
|
|
4
|
+
# time across the application, exceptions raised, and each host's
|
|
5
|
+
# writers. It runs every minute, so every read is a range on an indexed
|
|
6
|
+
# occurred_at column over that minute alone.
|
|
7
|
+
class HealthItem
|
|
8
|
+
def self.for_minute_before(time = Time.current)
|
|
9
|
+
window_start = (time - 1.minute).beginning_of_minute
|
|
10
|
+
new(window_start).item
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(window_start)
|
|
14
|
+
@window = window_start...(window_start + 1.minute)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def item
|
|
18
|
+
durations, statuses = request_rows
|
|
19
|
+
sorted = durations.compact.sort
|
|
20
|
+
{
|
|
21
|
+
type: "health",
|
|
22
|
+
window_start: @window.begin.utc.iso8601,
|
|
23
|
+
window_end: @window.end.utc.iso8601,
|
|
24
|
+
request_count: durations.size,
|
|
25
|
+
error_count: statuses.count { |status| status.to_i >= 500 },
|
|
26
|
+
avg_duration: sorted.any? ? sorted.sum.to_f / sorted.size : nil,
|
|
27
|
+
p95_duration: Statistics.calculate_percentile(sorted, 0.95)&.to_f,
|
|
28
|
+
exception_count: exception_count,
|
|
29
|
+
hosts: hosts
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def request_rows
|
|
36
|
+
rows = Request.where(occurred_at: @window).pluck(:duration, :status)
|
|
37
|
+
[ rows.map(&:first), rows.map(&:last) ]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def exception_count
|
|
41
|
+
return 0 unless ExceptionOccurrence.table_exists?
|
|
42
|
+
|
|
43
|
+
ExceptionOccurrence.where(occurred_at: @window).count
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# One entry per host from the writer heartbeats: the processes that
|
|
47
|
+
# reported recently, their latest queue depth, and requests dropped
|
|
48
|
+
# inside the minute. Process IDs are not sent.
|
|
49
|
+
def hosts
|
|
50
|
+
return [] unless Event.table_available?
|
|
51
|
+
|
|
52
|
+
recent = WriterHeartbeat.events
|
|
53
|
+
.where(occurred_at: (@window.end - WriterHeartbeat::LIVE_WINDOW)...@window.end)
|
|
54
|
+
.recent
|
|
55
|
+
.to_a
|
|
56
|
+
latest_per_process = recent.uniq(&:subject)
|
|
57
|
+
|
|
58
|
+
latest_per_process.group_by { |event| host_of(event) }.map do |host, events|
|
|
59
|
+
subjects = events.map(&:subject)
|
|
60
|
+
{
|
|
61
|
+
host: host,
|
|
62
|
+
processes: events.size,
|
|
63
|
+
queue_depth: events.sum { |event| event.metadata_hash["queue_depth"].to_i },
|
|
64
|
+
dropped: recent.select { |event| subjects.include?(event.subject) && @window.cover?(event.occurred_at) }.sum { |event| event.value.to_i },
|
|
65
|
+
last_heartbeat_at: events.map(&:occurred_at).max.utc.iso8601
|
|
66
|
+
}
|
|
67
|
+
end.sort_by { |entry| entry[:host].to_s }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# config.cloud.host_label when the host set one, otherwise its hostname.
|
|
71
|
+
def host_of(event)
|
|
72
|
+
metadata = event.metadata_hash
|
|
73
|
+
metadata["host_label"].presence || metadata["hostname"].presence || event.subject.to_s.rpartition(":").first
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# The prefix a request the router did not recognise is sent to Cloud
|
|
4
|
+
# under. The stored path is the raw URL, which can carry tokens, IDs or
|
|
5
|
+
# email addresses, so only a first segment that looks like code
|
|
6
|
+
# (`/wp-admin`, `/.env`) survives, and everything after it becomes `/*`.
|
|
7
|
+
#
|
|
8
|
+
# PathPrefix.for("/wp-admin/setup.php") # => "/wp-admin/*"
|
|
9
|
+
# PathPrefix.for("/wp-login.php") # => "/wp-login.php"
|
|
10
|
+
# PathPrefix.for("/users/jane.doe@example.com") # => "/users/*"
|
|
11
|
+
# PathPrefix.for("/8f3a9c1e2b7d") # => "/*"
|
|
12
|
+
module PathPrefix
|
|
13
|
+
# Every prefix past the busiest in an hour is combined under this one.
|
|
14
|
+
OTHER = "*".freeze
|
|
15
|
+
|
|
16
|
+
SAFE_SEGMENT = /\A[A-Za-z0-9._-]{1,40}\z/
|
|
17
|
+
DIGIT_RUN = /\d{4}/
|
|
18
|
+
HEXADECIMAL = /\A\h{8,}\z/
|
|
19
|
+
UUID = /\h{8}-\h{4}-\h{4}-\h{4}-\h{12}/
|
|
20
|
+
EMAIL_MARKERS = [ "@", "%40" ].freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def for(path)
|
|
25
|
+
path = path.to_s
|
|
26
|
+
return "/" if path == "/"
|
|
27
|
+
|
|
28
|
+
first, rest = path.delete_prefix("/").split("/", 2)
|
|
29
|
+
return "/*" unless safe_segment?(first)
|
|
30
|
+
|
|
31
|
+
rest.nil? ? "/#{first}" : "/#{first}/*"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# True for a path segment that reads as part of the application rather
|
|
35
|
+
# than something a user supplied.
|
|
36
|
+
def safe_segment?(segment)
|
|
37
|
+
return false unless segment.is_a?(String) && segment.match?(SAFE_SEGMENT)
|
|
38
|
+
|
|
39
|
+
!identifier?(segment)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def identifier?(segment)
|
|
43
|
+
segment.match?(DIGIT_RUN) ||
|
|
44
|
+
segment.match?(HEXADECIMAL) ||
|
|
45
|
+
segment.match?(UUID) ||
|
|
46
|
+
EMAIL_MARKERS.any? { |marker| segment.include?(marker) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module Cloud
|
|
5
|
+
# What `rails rails_pulse:cloud:preview` prints: the batches the next
|
|
6
|
+
# hourly sync would queue, and the health update for the minute just
|
|
7
|
+
# ended. It reads the same plan and builds items with the same code as
|
|
8
|
+
# Sync, so the preview cannot drift from what is sent, and it writes
|
|
9
|
+
# nothing: no installation row, no buffered batch, no request.
|
|
10
|
+
class Preview
|
|
11
|
+
METADATA_NOTICE = "Deployment metadata is sent as recorded: everything your deploy script puts in it goes to Rails Pulse Cloud.".freeze
|
|
12
|
+
|
|
13
|
+
def initialize(settings: RailsPulse.configuration.cloud, now: Time.current)
|
|
14
|
+
@settings = settings
|
|
15
|
+
@now = now
|
|
16
|
+
@installation = Installation.table_exists? ? Installation.existing : nil
|
|
17
|
+
rescue ActiveRecord::ActiveRecordError
|
|
18
|
+
@installation = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def plan
|
|
22
|
+
@plan ||= Sync.new(settings: @settings, now: @now).plan_for(@installation)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def batches
|
|
26
|
+
route_patterns = RoutePattern.new
|
|
27
|
+
items = plan.hours.flat_map do |hour|
|
|
28
|
+
SummaryItems.new(hour, adapter: envelope[:database_adapter], route_patterns: route_patterns).items
|
|
29
|
+
end
|
|
30
|
+
items += RecordItems.exception_groups_updated(plan.exception_groups)
|
|
31
|
+
items += RecordItems.deployments_updated(plan.deployments)
|
|
32
|
+
Batch.build(items, envelope: envelope)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def health_batch
|
|
36
|
+
Batch.new([ HealthItem.for_minute_before(@now) ], envelope: envelope, sent_at: @now)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def render
|
|
40
|
+
lines = [ "Rails Pulse Cloud preview", "" ]
|
|
41
|
+
lines << status_line
|
|
42
|
+
lines << "Installation ID: assigned on the first sync" if @installation.nil?
|
|
43
|
+
lines << ""
|
|
44
|
+
|
|
45
|
+
built = batches
|
|
46
|
+
if plan.hours.empty?
|
|
47
|
+
lines << "No newly summarized hour to send. Is SummaryJob scheduled?"
|
|
48
|
+
else
|
|
49
|
+
lines << "Next hourly sync: #{plan.hours.map { |hour| hour.utc.iso8601 }.join(', ')}."
|
|
50
|
+
end
|
|
51
|
+
lines << "#{pluralize(built.sum { |batch| batch.items.size }, 'item')} in #{pluralize(built.size, 'batch')}."
|
|
52
|
+
lines << METADATA_NOTICE
|
|
53
|
+
lines << ""
|
|
54
|
+
built.each { |batch| lines << JSON.pretty_generate(batch.to_h) }
|
|
55
|
+
|
|
56
|
+
lines << ""
|
|
57
|
+
lines << "Health update for the minute just ended, sent every minute and never buffered:"
|
|
58
|
+
lines << JSON.pretty_generate(health_batch.to_h)
|
|
59
|
+
lines.join("\n") + "\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def envelope
|
|
65
|
+
@envelope ||= Batch.envelope(application: @settings.application, environment: @settings.environment,
|
|
66
|
+
installation_id: @installation&.installation_id)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def status_line
|
|
70
|
+
if @settings.enabled?
|
|
71
|
+
"Sending to #{@settings.url} as #{@settings.application} (#{@settings.environment})."
|
|
72
|
+
else
|
|
73
|
+
"Not sending: set config.cloud.api_key and config.cloud.application to turn Rails Pulse Cloud on. " \
|
|
74
|
+
"Until then no network calls are made."
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pluralize(count, word)
|
|
79
|
+
"#{count} #{count == 1 ? word : word.pluralize}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# What Cloud receives for a query, rebuilt from the normalised SQL by
|
|
4
|
+
# allow-list. Normalisation replaces the literals it recognises with `?`,
|
|
5
|
+
# so a literal in a form it does not recognise stays in the stored SQL;
|
|
6
|
+
# here every token must be a keyword, an identifier, an operator, a
|
|
7
|
+
# number or a placeholder, and a query with anything else has no shape.
|
|
8
|
+
#
|
|
9
|
+
# shape = QueryShape.new('SELECT "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?')
|
|
10
|
+
# shape.sql_shape # => "SELECT users.* FROM users WHERE users.email = ? LIMIT ?"
|
|
11
|
+
# shape.label # => "SELECT users"
|
|
12
|
+
#
|
|
13
|
+
# QueryShape.new("SELECT * FROM notes WHERE body = $$jane@example.com$$").sql_shape # => nil
|
|
14
|
+
class QueryShape
|
|
15
|
+
MAX_LENGTH = 2_000
|
|
16
|
+
MAX_LABEL_TABLES = 5
|
|
17
|
+
|
|
18
|
+
IDENTIFIER = /[A-Za-z_][A-Za-z0-9_$]*/
|
|
19
|
+
BARE_IDENTIFIER = /\A#{IDENTIFIER}\z/
|
|
20
|
+
NUMBER = /\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/
|
|
21
|
+
OPERATOR = %r{[=<>!+\-*/%:|]+}
|
|
22
|
+
PUNCTUATION = /[.,();]/
|
|
23
|
+
PLACEHOLDER = /\?|\$\d+/
|
|
24
|
+
# Comments carry no shape, and query log tags put request IDs in them.
|
|
25
|
+
COMMENT = %r{/\*.*?(?:\*/|\z)|--[^\n]*}m
|
|
26
|
+
|
|
27
|
+
# Keywords after which the next name is a table.
|
|
28
|
+
TABLE_KEYWORDS = %w[FROM JOIN INTO UPDATE].freeze
|
|
29
|
+
|
|
30
|
+
# Keywords that take a parenthesised list, so `IN (?, ?)` is not
|
|
31
|
+
# rendered like a function call.
|
|
32
|
+
KEYWORDS_BEFORE_PARENTHESIS = %w[
|
|
33
|
+
ALL AND ANY AS EXISTS FROM IN INTO JOIN LATERAL NOT ON OR SELECT SET SOME THEN UNION USING VALUES WHEN WHERE
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# MySQL quotes identifiers with backticks; a double-quoted token there
|
|
37
|
+
# is a string, whatever it contains.
|
|
38
|
+
def self.for_adapter(sql, adapter)
|
|
39
|
+
new(sql, double_quotes_are_strings: adapter.to_s.match?(/mysql|trilogy/i))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(sql, double_quotes_are_strings: false)
|
|
43
|
+
@sql = sql.to_s.gsub(COMMENT, " ")
|
|
44
|
+
@double_quotes_are_strings = double_quotes_are_strings
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The shape, or nil when any token is not on the allow-list.
|
|
48
|
+
def sql_shape
|
|
49
|
+
return @sql_shape if defined?(@sql_shape)
|
|
50
|
+
|
|
51
|
+
@sql_shape = tokens.any? { |kind, _| kind == :unsafe } ? nil : render.first(MAX_LENGTH)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The statement type and up to five table names, always available
|
|
55
|
+
# because it is built only from tokens that pass.
|
|
56
|
+
def label
|
|
57
|
+
first_kind, first_text = tokens.first
|
|
58
|
+
statement = first_kind == :word ? first_text.upcase : "QUERY"
|
|
59
|
+
tables = table_names
|
|
60
|
+
tables.empty? ? statement : "#{statement} #{tables.join(', ')}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# [[kind, text], ...] where kind is :word, :name (a quoted identifier,
|
|
66
|
+
# unquoted), :number, :placeholder, :operator or :unsafe.
|
|
67
|
+
def tokens
|
|
68
|
+
@tokens ||= begin
|
|
69
|
+
scanner = StringScanner.new(@sql)
|
|
70
|
+
result = []
|
|
71
|
+
until scanner.eos?
|
|
72
|
+
next if scanner.skip(/\s+/)
|
|
73
|
+
|
|
74
|
+
result << next_token(scanner)
|
|
75
|
+
end
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def next_token(scanner)
|
|
81
|
+
if (text = scanner.scan(IDENTIFIER)) then [ :word, text ]
|
|
82
|
+
elsif scanner.scan(NUMBER) then [ :number, "?" ]
|
|
83
|
+
elsif (text = scanner.scan(PLACEHOLDER)) then [ :placeholder, "?" ]
|
|
84
|
+
elsif (text = scanner.scan(/"(?:[^"]|"")*"?/)) then quoted(text, '"')
|
|
85
|
+
elsif (text = scanner.scan(/`[^`]*`?/)) then quoted(text, "`")
|
|
86
|
+
elsif (text = scanner.scan(/'(?:[^']|'')*'?/)) then [ :unsafe, text ]
|
|
87
|
+
elsif (text = scanner.scan(PUNCTUATION)) then [ :operator, text ]
|
|
88
|
+
elsif (text = scanner.scan(OPERATOR)) then [ :operator, text ]
|
|
89
|
+
else [ :unsafe, scanner.getch ]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def quoted(text, quote)
|
|
94
|
+
return [ :unsafe, text ] if quote == '"' && @double_quotes_are_strings
|
|
95
|
+
|
|
96
|
+
content = text.delete_prefix(quote).delete_suffix(quote)
|
|
97
|
+
content.match?(BARE_IDENTIFIER) && text.end_with?(quote) && text.length > 1 ? [ :name, content ] : [ :unsafe, text ]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Tokens rejoined with single spaces, except none around `.`, after `(`,
|
|
101
|
+
# before `)` and `,`, or between a function name and its `(`, with
|
|
102
|
+
# schema qualifiers dropped.
|
|
103
|
+
def render
|
|
104
|
+
output = +""
|
|
105
|
+
tokens = without_schemas
|
|
106
|
+
tokens.each_with_index do |token, index|
|
|
107
|
+
output << " " if index.positive? && space_between?(tokens[index - 1], token, index > 1 ? tokens[index - 2] : nil)
|
|
108
|
+
output << token.last
|
|
109
|
+
end
|
|
110
|
+
output
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def space_between?(previous, token, earlier)
|
|
114
|
+
before = previous.last
|
|
115
|
+
text = token.last
|
|
116
|
+
return false if before == "." || text == "." || before == "(" || text == ")" || text == ","
|
|
117
|
+
return false if text == "(" && function_name?(previous, earlier)
|
|
118
|
+
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A word before `(` is a function call, unless it is a keyword or the
|
|
123
|
+
# table named after INTO (`INSERT INTO posts (title)`).
|
|
124
|
+
def function_name?(token, earlier)
|
|
125
|
+
kind, text = token
|
|
126
|
+
return false unless kind == :word && !KEYWORDS_BEFORE_PARENTHESIS.include?(text.upcase)
|
|
127
|
+
|
|
128
|
+
!(earlier && earlier.first == :word && TABLE_KEYWORDS.include?(earlier.last.upcase))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# A three-part name (schema.table.column) loses its schema anywhere; a
|
|
132
|
+
# two-part name right after FROM, JOIN, INTO or UPDATE is schema.table
|
|
133
|
+
# and loses its schema too. Apps with one schema per tenant name their
|
|
134
|
+
# customers there.
|
|
135
|
+
def without_schemas
|
|
136
|
+
list = tokens.dup
|
|
137
|
+
index = 0
|
|
138
|
+
result = []
|
|
139
|
+
while index < list.size
|
|
140
|
+
if qualified_name?(list, index, 3)
|
|
141
|
+
index += 2
|
|
142
|
+
elsif qualified_name?(list, index, 2) && table_position?(result)
|
|
143
|
+
index += 2
|
|
144
|
+
end
|
|
145
|
+
result << list[index]
|
|
146
|
+
index += 1
|
|
147
|
+
end
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A name of `parts` identifiers joined by dots starts at `index`.
|
|
152
|
+
def qualified_name?(list, index, parts)
|
|
153
|
+
(0...parts).all? do |part|
|
|
154
|
+
kind, text = list[index + (part * 2)]
|
|
155
|
+
dot = list[index + (part * 2) + 1]
|
|
156
|
+
name = %i[word name].include?(kind)
|
|
157
|
+
last = part == parts - 1
|
|
158
|
+
name && (last || dot&.last == ".") && (!last || text != "*")
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def table_position?(result)
|
|
163
|
+
kind, text = result.last
|
|
164
|
+
kind == :word && TABLE_KEYWORDS.include?(text.upcase)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def table_names
|
|
168
|
+
names = []
|
|
169
|
+
tokens.each_with_index do |(kind, text), index|
|
|
170
|
+
next unless kind == :word && TABLE_KEYWORDS.include?(text.upcase)
|
|
171
|
+
|
|
172
|
+
name = table_after(index + 1)
|
|
173
|
+
names << name if name && !names.include?(name)
|
|
174
|
+
break if names.size >= MAX_LABEL_TABLES
|
|
175
|
+
end
|
|
176
|
+
names
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# The table named after a keyword, with any schema dropped. Nil when
|
|
180
|
+
# what follows is not a plain name (a subquery, or anything unsafe).
|
|
181
|
+
def table_after(index)
|
|
182
|
+
name = nil
|
|
183
|
+
loop do
|
|
184
|
+
kind, text = tokens[index]
|
|
185
|
+
return name unless %i[word name].include?(kind)
|
|
186
|
+
|
|
187
|
+
name = text
|
|
188
|
+
return name unless tokens[index + 1]&.last == "."
|
|
189
|
+
|
|
190
|
+
index += 2
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Cloud
|
|
3
|
+
# Items for exception groups and deployments, the two records Cloud
|
|
4
|
+
# keeps whole rather than as hourly numbers. Each is sent when it
|
|
5
|
+
# changes: a group when its status or counts move, a deployment when it
|
|
6
|
+
# is recorded or finished.
|
|
7
|
+
module RecordItems
|
|
8
|
+
# Larger metadata is replaced rather than cut, so Cloud never shows a
|
|
9
|
+
# half of what the deploy script recorded.
|
|
10
|
+
METADATA_LIMIT = 4.kilobytes
|
|
11
|
+
TRUNCATED = { "truncated" => true }.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Items for the groups whose rows changed inside `window`.
|
|
16
|
+
def exception_groups_updated(window)
|
|
17
|
+
ExceptionGroup.where(updated_at: window).order(:id).map { |record| exception_group(record) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Items for the deployments recorded or finished inside `window`.
|
|
21
|
+
def deployments_updated(window)
|
|
22
|
+
Deployment.where(updated_at: window).order(:started_at, :id).map { |record| deployment(record) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The message is never sent: it is built from runtime values and can
|
|
26
|
+
# carry anything a user typed.
|
|
27
|
+
def exception_group(group)
|
|
28
|
+
{
|
|
29
|
+
type: "exception_group",
|
|
30
|
+
fingerprint: group.fingerprint,
|
|
31
|
+
exception_class: group.exception_class,
|
|
32
|
+
location: group.location,
|
|
33
|
+
status: group.status,
|
|
34
|
+
first_seen_at: group.first_seen_at&.utc&.iso8601,
|
|
35
|
+
last_seen_at: group.last_seen_at&.utc&.iso8601,
|
|
36
|
+
resolved_at: group.resolved_at&.utc&.iso8601,
|
|
37
|
+
occurrence_count: group.occurrence_count.to_i
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Metadata is sent as recorded: the deploy script decides what goes in
|
|
42
|
+
# it, and the preview task says so.
|
|
43
|
+
def deployment(deployment)
|
|
44
|
+
{
|
|
45
|
+
type: "deployment",
|
|
46
|
+
revision: deployment.revision,
|
|
47
|
+
started_at: deployment.started_at.utc.iso8601,
|
|
48
|
+
finished_at: deployment.finished_at&.utc&.iso8601,
|
|
49
|
+
metadata: metadata(deployment.metadata_hash)
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def metadata(value)
|
|
54
|
+
return {} unless value.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
value.to_json.bytesize > METADATA_LIMIT ? TRUNCATED : value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|