upright 0.2.0 → 0.4.0
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/README.md +208 -56
- data/Rakefile +22 -0
- data/app/assets/images/upright/.keep +0 -0
- data/app/assets/stylesheets/upright/_global.css +2 -0
- data/app/assets/stylesheets/upright/artifact.css +7 -25
- data/app/assets/stylesheets/upright/flash.css +19 -0
- data/app/assets/stylesheets/upright/header.css +14 -0
- data/app/assets/stylesheets/upright/incidents.css +610 -0
- data/app/assets/stylesheets/upright/map.css +6 -0
- data/app/assets/stylesheets/upright/public_status.css +534 -0
- data/app/assets/stylesheets/upright/uptime-bars.css +10 -1
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/upright/authentication.rb +1 -0
- data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
- data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
- data/app/controllers/upright/artifacts_controller.rb +1 -1
- data/app/controllers/upright/incidents/updates_controller.rb +13 -0
- data/app/controllers/upright/incidents_controller.rb +53 -0
- data/app/controllers/upright/probe_results_controller.rb +1 -0
- data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
- data/app/controllers/upright/public/base_controller.rb +16 -0
- data/app/controllers/upright/public/incidents_controller.rb +6 -0
- data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
- data/app/controllers/upright/public/services_controller.rb +6 -0
- data/app/controllers/upright/sessions_controller.rb +37 -1
- data/app/controllers/upright/traces_controller.rb +88 -0
- data/app/helpers/upright/application_helper.rb +22 -2
- data/app/helpers/upright/artifacts_helper.rb +20 -0
- data/app/helpers/upright/probe_results_helper.rb +4 -10
- data/app/helpers/upright/public/services_helper.rb +70 -0
- data/app/javascript/upright/application.js +3 -0
- data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
- data/app/javascript/upright/controllers/template_controller.js +11 -0
- data/app/javascript/upright/public.js +5 -0
- data/app/jobs/upright/health_metrics_job.rb +27 -0
- data/app/jobs/upright/incident_reporter_job.rb +7 -0
- data/app/jobs/upright/maintenance_advance_job.rb +8 -0
- data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
- data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
- data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
- data/app/models/concerns/upright/playwright/form_authentication.rb +3 -5
- data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
- data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
- data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
- data/app/models/concerns/upright/probe_result/stale_cleanup.rb +47 -0
- data/app/models/concerns/upright/probeable.rb +16 -2
- data/app/models/concerns/upright/services/live_status.rb +69 -0
- data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
- data/app/models/upright/artifact.rb +2 -1
- data/app/models/upright/http/request.rb +13 -4
- data/app/models/upright/incident/automatic_report.rb +3 -0
- data/app/models/upright/incident.rb +87 -0
- data/app/models/upright/incident_affected_service.rb +9 -0
- data/app/models/upright/incident_update.rb +7 -0
- data/app/models/upright/maintenance.rb +36 -0
- data/app/models/upright/persistent_record.rb +12 -0
- data/app/models/upright/playwright/authenticator/base.rb +50 -70
- data/app/models/upright/probe_result.rb +6 -1
- data/app/models/upright/probes/http_probe.rb +30 -1
- data/app/models/upright/probes/playwright/base.rb +2 -0
- data/app/models/upright/probes/status/probe.rb +8 -0
- data/app/models/upright/probes/status.rb +5 -13
- data/app/models/upright/probes/uptime.rb +5 -12
- data/app/models/upright/rollups/daily_uptime.rb +28 -0
- data/app/models/upright/rollups/probe_rollup.rb +64 -0
- data/app/models/upright/rollups/probe_uptime.rb +9 -0
- data/app/models/upright/rollups/site_uptime.rb +75 -0
- data/app/models/upright/service/daily_status.rb +39 -0
- data/app/models/upright/service/incident_history.rb +24 -0
- data/app/models/upright/service/outage.rb +15 -0
- data/app/models/upright/service/status_page.rb +35 -0
- data/app/models/upright/service.rb +101 -0
- data/app/models/upright/status.rb +21 -0
- data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
- data/app/models/upright/traceroute/result.rb +8 -1
- data/app/views/layouts/upright/_header.html.erb +12 -2
- data/app/views/layouts/upright/application.html.erb +4 -1
- data/app/views/layouts/upright/public.html.erb +18 -0
- data/app/views/layouts/upright/public.rss.builder +1 -0
- data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
- data/app/views/upright/artifacts/show.html.erb +8 -0
- data/app/views/upright/dashboards/_uptime_probe_row.html.erb +5 -3
- data/app/views/upright/dashboards/probe_statuses/show.html.erb +1 -1
- data/app/views/upright/dashboards/uptimes/show.html.erb +1 -1
- data/app/views/upright/incidents/_form.html.erb +93 -0
- data/app/views/upright/incidents/_row.html.erb +15 -0
- data/app/views/upright/incidents/edit.html.erb +64 -0
- data/app/views/upright/incidents/index.html.erb +53 -0
- data/app/views/upright/incidents/new.html.erb +6 -0
- data/app/views/upright/probe_results/index.html.erb +7 -4
- data/app/views/upright/public/_branding.html.erb +3 -0
- data/app/views/upright/public/_footer.html.erb +3 -0
- data/app/views/upright/public/incidents/show.html.erb +29 -0
- data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
- data/app/views/upright/public/service_incidents/index.html.erb +50 -0
- data/app/views/upright/public/services/_active_events.html.erb +28 -0
- data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
- data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
- data/app/views/upright/public/services/_service.html.erb +34 -0
- data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
- data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
- data/app/views/upright/public/services/index.html.erb +13 -0
- data/app/views/upright/public/services/index.rss.builder +29 -0
- data/app/views/upright/sessions/new.html.erb +14 -1
- data/config/ci.rb +2 -0
- data/config/importmap.rb +25 -14
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/mime_types.rb +1 -0
- data/config/routes.rb +26 -2
- data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
- data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
- data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
- data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
- data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
- data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
- data/lib/generators/upright/install/database_config.rb +46 -0
- data/lib/generators/upright/install/install_generator.rb +52 -3
- data/lib/generators/upright/install/templates/Dockerfile +80 -0
- data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
- data/lib/generators/upright/install/templates/deploy.yml +13 -13
- data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
- data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
- data/lib/generators/upright/install/templates/http_probes.yml +1 -0
- data/lib/generators/upright/install/templates/omniauth.rb +7 -3
- data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
- data/lib/generators/upright/install/templates/recurring.yml +45 -0
- data/lib/generators/upright/install/templates/sites.yml +4 -0
- data/lib/generators/upright/install/templates/smtp_probes.yml +3 -0
- data/lib/generators/upright/install/templates/traceroute_probes.yml +3 -0
- data/lib/generators/upright/install/templates/upright.rb +24 -2
- data/lib/generators/upright/install/templates/upright.rules.yml +14 -7
- data/lib/upright/configuration.rb +127 -6
- data/lib/upright/engine.rb +32 -2
- data/lib/upright/metrics.rb +27 -2
- data/lib/upright/probe_type_registry.rb +33 -0
- data/lib/upright/site.rb +31 -1
- data/lib/upright/version.rb +3 -1
- data/lib/upright.rb +34 -2
- metadata +110 -7
- data/config/credentials/development.key +0 -1
- data/config/credentials/test.key +0 -1
- data/lib/tasks/upright_tasks.rake +0 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
class Upright::Incident < Upright::PersistentRecord
|
|
2
|
+
include Upright::Incidents::Lifecycle
|
|
3
|
+
include Upright::Incidents::AutoReporting
|
|
4
|
+
|
|
5
|
+
attr_accessor :body
|
|
6
|
+
|
|
7
|
+
enum :status, %w[ investigating monitoring resolved
|
|
8
|
+
scheduled in_progress completed ].index_with(&:itself)
|
|
9
|
+
enum :impact, %w[ minor major critical maintenance ].index_with(&:itself), prefix: true, default: "minor"
|
|
10
|
+
|
|
11
|
+
STATUSES = %w[ investigating monitoring resolved ]
|
|
12
|
+
TERMINAL_STATUSES = %w[ resolved ]
|
|
13
|
+
IMPACTS = %w[ minor major critical ]
|
|
14
|
+
|
|
15
|
+
IMPACT_STATUS = { "minor" => :degraded, "major" => :partial_outage, "critical" => :major_outage }
|
|
16
|
+
|
|
17
|
+
TEMPLATES = {
|
|
18
|
+
down: "%{services} %{is_or_are} down. We are investigating.",
|
|
19
|
+
investigating: "We are investigating the cause and will post updates here.",
|
|
20
|
+
back_up: "%{services} %{is_or_are} back up and operating normally."
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def self.active_statuses
|
|
24
|
+
reactive.active.map { |incident| IMPACT_STATUS.fetch(incident.impact) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
has_many :updates, -> { order(created_at: :desc) }, class_name: "Upright::IncidentUpdate", inverse_of: :incident, dependent: :destroy
|
|
28
|
+
has_many :affected_services, class_name: "Upright::IncidentAffectedService", inverse_of: :incident, dependent: :destroy
|
|
29
|
+
has_one :automatic_report, class_name: "Upright::Incident::AutomaticReport", inverse_of: :incident, dependent: :destroy
|
|
30
|
+
|
|
31
|
+
# An incident is public-facing when it affects at least one public service.
|
|
32
|
+
# Everything else — internal-only or untagged — stays off the public status
|
|
33
|
+
# page entirely (fail closed).
|
|
34
|
+
scope :public_facing, -> { for_service(Upright::Service.public_facing.map(&:code)).distinct }
|
|
35
|
+
|
|
36
|
+
validates :title, :starts_at, presence: true
|
|
37
|
+
validates :status, inclusion: { in: ->(incident) { incident.class::STATUSES } }
|
|
38
|
+
validates :impact, inclusion: { in: ->(incident) { incident.class::IMPACTS } }
|
|
39
|
+
|
|
40
|
+
before_validation :set_default_status, on: :create
|
|
41
|
+
before_create { self.created_by ||= Upright::Current.user&.name }
|
|
42
|
+
before_update { self.updated_by = Upright::Current.user.name if Upright::Current.user }
|
|
43
|
+
after_create :record_initial_update
|
|
44
|
+
|
|
45
|
+
def maintenance? = false
|
|
46
|
+
|
|
47
|
+
def service_codes = affected_services.map(&:service_code)
|
|
48
|
+
|
|
49
|
+
def service_codes=(codes)
|
|
50
|
+
self.affected_services = Array(codes).reject(&:blank?).uniq.map do |code|
|
|
51
|
+
affected_services.find { |s| s.service_code == code } ||
|
|
52
|
+
affected_services.build(service_code: code)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def services
|
|
57
|
+
service_codes.filter_map { |code| Upright::Service.find_by(code: code) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def public_services
|
|
61
|
+
services.select { |service| service[:public] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def update_body_for(key)
|
|
65
|
+
subjects = services
|
|
66
|
+
template = subjects.filter_map { |service| service.incident_update_template(key) }.uniq
|
|
67
|
+
template = template.one? ? template.first : TEMPLATES.fetch(key)
|
|
68
|
+
|
|
69
|
+
template % {
|
|
70
|
+
services: subjects.any? ? subjects.map(&:name).to_sentence : "This service",
|
|
71
|
+
is_or_are: subjects.many? ? "are" : "is"
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
def set_default_status
|
|
77
|
+
self.status ||= self.class::STATUSES.first
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def record_initial_update
|
|
81
|
+
updates.create!(status: status, body: body.presence || default_initial_body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def default_initial_body
|
|
85
|
+
maintenance? ? "Maintenance scheduled." : "We are investigating."
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class Upright::IncidentAffectedService < Upright::PersistentRecord
|
|
2
|
+
belongs_to :incident, class_name: "Upright::Incident", inverse_of: :affected_services
|
|
3
|
+
|
|
4
|
+
validates :service_code, presence: true,
|
|
5
|
+
uniqueness: { scope: :incident_id },
|
|
6
|
+
inclusion: { in: ->(_) { Upright::Service.all.map(&:code) } }
|
|
7
|
+
|
|
8
|
+
def service = Upright::Service.find_by(code: service_code)
|
|
9
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class Upright::Maintenance < Upright::Incident
|
|
2
|
+
STATUSES = %w[ scheduled in_progress completed ]
|
|
3
|
+
TERMINAL_STATUSES = %w[ completed ]
|
|
4
|
+
IMPACTS = %w[ maintenance ]
|
|
5
|
+
|
|
6
|
+
SUPPRESSION_LEAD = 1.minute
|
|
7
|
+
|
|
8
|
+
scope :suppressing, -> { unresolved.where(starts_at: ..SUPPRESSION_LEAD.from_now) }
|
|
9
|
+
|
|
10
|
+
validates :ends_at, presence: true
|
|
11
|
+
validate :ends_after_start
|
|
12
|
+
|
|
13
|
+
before_validation :set_maintenance_impact
|
|
14
|
+
|
|
15
|
+
def maintenance? = true
|
|
16
|
+
|
|
17
|
+
def self.export_service_metrics
|
|
18
|
+
Upright::Service.all.each do |service|
|
|
19
|
+
Yabeda.upright_service_under_maintenance.set({ probe_service: service.code }, suppressing.for_service(service.code).exists? ? 1 : 0)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def auto_advance_status(now: Time.current)
|
|
24
|
+
record_update(status: "in_progress", body: "Maintenance is underway.") if scheduled? && now >= starts_at
|
|
25
|
+
record_update(status: "completed", body: "Maintenance is complete.") if in_progress? && now >= ends_at
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
def set_maintenance_impact
|
|
30
|
+
self.impact = "maintenance"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def ends_after_start
|
|
34
|
+
errors.add(:ends_at, "must be after the start") if ends_at.present? && starts_at.present? && ends_at <= starts_at
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Durable, replicated source-of-truth data (rollups, later incidents/maintenance) on the persistent DB.
|
|
2
|
+
class Upright::PersistentRecord < ActiveRecord::Base
|
|
3
|
+
self.abstract_class = true
|
|
4
|
+
|
|
5
|
+
connects_to database: { writing: :persistent, reading: :persistent }
|
|
6
|
+
|
|
7
|
+
def self.up?
|
|
8
|
+
with_connection { |connection| connection.select_value("SELECT 1") }.present?
|
|
9
|
+
rescue StandardError
|
|
10
|
+
false
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -7,14 +7,24 @@ class Upright::Playwright::Authenticator::Base
|
|
|
7
7
|
new.authenticate_on(page)
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def initialize
|
|
11
|
-
@browser = browser
|
|
12
|
-
@context_options = context_options
|
|
10
|
+
def initialize
|
|
13
11
|
@storage_state = Upright::Playwright::StorageState.new(service_name)
|
|
14
12
|
end
|
|
15
13
|
|
|
14
|
+
def ensure_authenticated(context, page)
|
|
15
|
+
@page = page
|
|
16
|
+
load_cached_storage_state(context)
|
|
17
|
+
page.goto(auth_check_url, timeout: 10.seconds.in_ms)
|
|
18
|
+
|
|
19
|
+
unless session_valid_on?(page)
|
|
20
|
+
authenticate_on(page)
|
|
21
|
+
@storage_state.save(context.storage_state)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
16
25
|
def authenticate_on(page)
|
|
17
26
|
@page = page
|
|
27
|
+
setup_page_logging(page)
|
|
18
28
|
authenticate
|
|
19
29
|
self
|
|
20
30
|
end
|
|
@@ -30,14 +40,9 @@ class Upright::Playwright::Authenticator::Base
|
|
|
30
40
|
end
|
|
31
41
|
end
|
|
32
42
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return context if context_has_valid_session?(context)
|
|
37
|
-
context.close
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
perform_authentication
|
|
43
|
+
def session_valid_on?(page)
|
|
44
|
+
wait_for_network_idle(page)
|
|
45
|
+
!page.url.include?(signin_path)
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
protected
|
|
@@ -50,79 +55,54 @@ class Upright::Playwright::Authenticator::Base
|
|
|
50
55
|
raise NotImplementedError
|
|
51
56
|
end
|
|
52
57
|
|
|
53
|
-
private
|
|
54
|
-
|
|
55
58
|
def service_name
|
|
56
59
|
raise NotImplementedError
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def context_has_valid_session?(context)
|
|
64
|
-
page = context.new_page
|
|
65
|
-
page.goto(signin_redirect_url, timeout: 10.seconds.in_ms)
|
|
66
|
-
!page.url.include?(signin_path)
|
|
67
|
-
rescue ::Playwright::TimeoutError
|
|
68
|
-
false
|
|
69
|
-
ensure
|
|
70
|
-
page&.close
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def perform_authentication
|
|
74
|
-
context = create_context
|
|
75
|
-
@page = context.new_page
|
|
76
|
-
setup_page_logging(page)
|
|
77
|
-
|
|
78
|
-
authenticate
|
|
79
|
-
|
|
80
|
-
state = context.storage_state
|
|
81
|
-
@storage_state.save(state)
|
|
82
|
-
context.close
|
|
83
|
-
|
|
84
|
-
create_context(state)
|
|
85
|
-
end
|
|
62
|
+
private
|
|
63
|
+
def auth_check_url
|
|
64
|
+
signin_redirect_url
|
|
65
|
+
end
|
|
86
66
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
end
|
|
67
|
+
def authenticate
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
end
|
|
91
70
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
71
|
+
def load_cached_storage_state(context)
|
|
72
|
+
if (cached_state = @storage_state.load)
|
|
73
|
+
cached_state.fetch("cookies", []).each do |cookie|
|
|
74
|
+
context.add_cookies([ cookie ])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
98
78
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
79
|
+
def setup_page_logging(page)
|
|
80
|
+
if defined?(RailsStructuredLogging::Recorder)
|
|
81
|
+
RailsStructuredLogging::Recorder.instance.messages.tap do |messages|
|
|
82
|
+
page.on("response", ->(response) {
|
|
83
|
+
next if skip_logging?(response)
|
|
84
|
+
RailsStructuredLogging::Recorder.instance.sharing(messages)
|
|
85
|
+
log_response(response)
|
|
86
|
+
})
|
|
87
|
+
end
|
|
88
|
+
else
|
|
102
89
|
page.on("response", ->(response) {
|
|
103
90
|
next if skip_logging?(response)
|
|
104
|
-
RailsStructuredLogging::Recorder.instance.sharing(messages)
|
|
105
91
|
log_response(response)
|
|
106
92
|
})
|
|
107
93
|
end
|
|
108
|
-
else
|
|
109
|
-
page.on("response", ->(response) {
|
|
110
|
-
next if skip_logging?(response)
|
|
111
|
-
log_response(response)
|
|
112
|
-
})
|
|
113
94
|
end
|
|
114
|
-
end
|
|
115
95
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
96
|
+
def log_response(response)
|
|
97
|
+
headers = response.headers.slice("x-request-id", "x-runtime").compact
|
|
98
|
+
Rails.logger.info "#{response.status} #{response.request.resource_type.upcase} #{response.url} #{headers.to_query}"
|
|
99
|
+
end
|
|
120
100
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
101
|
+
def skip_logging?(response)
|
|
102
|
+
%w[image asset avatar].any? { |pattern| response.url.include?(pattern) }
|
|
103
|
+
end
|
|
124
104
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
105
|
+
def credentials
|
|
106
|
+
Rails.application.credentials
|
|
107
|
+
end
|
|
128
108
|
end
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
class Upright::ProbeResult < Upright::ApplicationRecord
|
|
2
2
|
include Upright::ExceptionRecording
|
|
3
|
+
include Upright::ProbeResult::StaleCleanup
|
|
4
|
+
|
|
5
|
+
attr_accessor :probe_alert_severity
|
|
3
6
|
|
|
4
7
|
has_many_attached :artifacts
|
|
5
8
|
|
|
6
9
|
scope :by_type, ->(type) { where(probe_type: type) if type.present? }
|
|
7
10
|
scope :by_status, ->(status) { where(status: status) if status.present? }
|
|
8
11
|
scope :by_name, ->(name) { where(probe_name: name) if name.present? }
|
|
12
|
+
scope :by_date, ->(date) { where(created_at: Date.parse(date).all_day) if date.present? }
|
|
13
|
+
|
|
9
14
|
scope :stale, -> { where(created_at: ...24.hours.ago) }
|
|
10
15
|
|
|
11
16
|
enum :status, [ :ok, :fail ]
|
|
@@ -23,7 +28,7 @@ class Upright::ProbeResult < Upright::ApplicationRecord
|
|
|
23
28
|
|
|
24
29
|
private
|
|
25
30
|
def increment_metrics
|
|
26
|
-
labels = { type: probe_type, name: probe_name, probe_target: probe_target, probe_service: probe_service }
|
|
31
|
+
labels = { type: probe_type, name: probe_name, probe_target: probe_target, probe_service: probe_service, alert_severity: probe_alert_severity || :high }
|
|
27
32
|
|
|
28
33
|
Yabeda.upright_probe_duration_seconds.set(labels.merge(status: status), duration.to_f)
|
|
29
34
|
Yabeda.upright_probe_up.set(labels, ok? ? 1 : 0)
|
|
@@ -6,6 +6,11 @@ class Upright::Probes::HTTPProbe < FrozenRecord::Base
|
|
|
6
6
|
|
|
7
7
|
DEFAULT_EXPECTED_STATUS = 200..399
|
|
8
8
|
|
|
9
|
+
# Response bodies are stored as viewable/downloadable artifacts: cap their
|
|
10
|
+
# size and never store them as text/html, which would be same-origin XSS.
|
|
11
|
+
MAX_STORED_BODY_BYTES = 1.megabyte
|
|
12
|
+
TRUNCATION_NOTICE = "\n\n[TRUNCATED: response body exceeded #{MAX_STORED_BODY_BYTES} bytes]".freeze
|
|
13
|
+
|
|
9
14
|
attr_accessor :last_response
|
|
10
15
|
|
|
11
16
|
def check
|
|
@@ -100,7 +105,31 @@ class Upright::Probes::HTTPProbe < FrozenRecord::Base
|
|
|
100
105
|
|
|
101
106
|
def attach_response_body(probe_result)
|
|
102
107
|
if last_response && last_response.body.present?
|
|
103
|
-
|
|
108
|
+
# Attach directly with identify: false — the body is untrusted remote
|
|
109
|
+
# content, and ActiveStorage's content sniffing would otherwise restore
|
|
110
|
+
# text/html and make the stored artifact same-origin XSS when previewed.
|
|
111
|
+
probe_result.artifacts.attach(
|
|
112
|
+
io: StringIO.new(stored_body),
|
|
113
|
+
filename: "response.#{stored_body_extension}",
|
|
114
|
+
content_type: stored_body_content_type,
|
|
115
|
+
identify: false
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def stored_body_extension
|
|
121
|
+
last_response.file_extension == "html" ? "txt" : last_response.file_extension
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def stored_body_content_type
|
|
125
|
+
Marcel::MimeType.for(extension: stored_body_extension)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def stored_body
|
|
129
|
+
if last_response.body.bytesize > MAX_STORED_BODY_BYTES
|
|
130
|
+
last_response.body.byteslice(0, MAX_STORED_BODY_BYTES) + TRUNCATION_NOTICE
|
|
131
|
+
else
|
|
132
|
+
last_response.body
|
|
104
133
|
end
|
|
105
134
|
end
|
|
106
135
|
end
|
|
@@ -11,6 +11,7 @@ class Upright::Probes::Playwright::Base
|
|
|
11
11
|
include Upright::Playwright::Logging
|
|
12
12
|
include Upright::Playwright::OtelTracing
|
|
13
13
|
include Upright::Playwright::VideoRecording
|
|
14
|
+
include Upright::Playwright::TraceRecording
|
|
14
15
|
include Upright::Playwright::Helpers
|
|
15
16
|
|
|
16
17
|
set_callback :perform_check, :after, :wait_for_network_idle
|
|
@@ -39,6 +40,7 @@ class Upright::Probes::Playwright::Base
|
|
|
39
40
|
|
|
40
41
|
def on_check_recorded(probe_result)
|
|
41
42
|
attach_video(probe_result)
|
|
43
|
+
attach_trace(probe_result)
|
|
42
44
|
attach_log(probe_result)
|
|
43
45
|
end
|
|
44
46
|
|
|
@@ -10,6 +10,14 @@ class Upright::Probes::Status::Probe
|
|
|
10
10
|
@site_statuses = site_statuses
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
def self.key_for(name:, type:, probe_target:, **)
|
|
14
|
+
[ name, type, probe_target ]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def key
|
|
18
|
+
self.class.key_for(name:, type:, probe_target:)
|
|
19
|
+
end
|
|
20
|
+
|
|
13
21
|
def status_for_site(code)
|
|
14
22
|
site_statuses.find { |s| s.site_code == code.to_s }
|
|
15
23
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
class Upright::Probes::Status
|
|
2
2
|
class << self
|
|
3
3
|
def for_type(probe_type)
|
|
4
|
-
results = prometheus_client.query_range(
|
|
4
|
+
results = Upright.prometheus_client.query_range(
|
|
5
5
|
query: query(probe_type),
|
|
6
6
|
start: 30.minutes.ago.iso8601,
|
|
7
7
|
end: Time.current.iso8601,
|
|
@@ -17,21 +17,13 @@ class Upright::Probes::Status
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def label_selector(probe_type)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def prometheus_client
|
|
26
|
-
Prometheus::ApiClient.client(
|
|
27
|
-
url: ENV.fetch("PROMETHEUS_URL", "http://localhost:9090"),
|
|
28
|
-
options: { timeout: 30.seconds }
|
|
29
|
-
)
|
|
20
|
+
matchers = [ "alert_severity!=\"\"", "environment=\"#{Rails.env}\"" ]
|
|
21
|
+
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
22
|
+
"{#{matchers.join(",")}}"
|
|
30
23
|
end
|
|
31
24
|
|
|
32
25
|
def build_probes(results)
|
|
33
|
-
|
|
34
|
-
grouped = results.group_by { |r| [ r[:metric][:name], r[:metric][:type], r[:metric][:probe_target] ] }
|
|
26
|
+
grouped = results.group_by { |r| Probe.key_for(**r[:metric]) }
|
|
35
27
|
|
|
36
28
|
grouped.map do |(_name, _type, _target), series|
|
|
37
29
|
site_statuses = series.map do |s|
|
|
@@ -5,7 +5,7 @@ class Upright::Probes::Uptime
|
|
|
5
5
|
end
|
|
6
6
|
|
|
7
7
|
def for_type(probe_type)
|
|
8
|
-
results = prometheus_client.query_range(
|
|
8
|
+
results = Upright.prometheus_client.query_range(
|
|
9
9
|
query: query(probe_type),
|
|
10
10
|
start: 30.days.ago.iso8601,
|
|
11
11
|
end: Time.current.iso8601,
|
|
@@ -17,20 +17,13 @@ class Upright::Probes::Uptime
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
def query(probe_type)
|
|
20
|
-
"upright:probe_uptime_daily#{label_selector(probe_type)}"
|
|
20
|
+
"min by (name, type, probe_target) (upright:probe_uptime_daily#{label_selector(probe_type)})"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def label_selector(probe_type)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def prometheus_client
|
|
30
|
-
Prometheus::ApiClient.client(
|
|
31
|
-
url: ENV.fetch("PROMETHEUS_URL", "http://localhost:9090"),
|
|
32
|
-
options: { timeout: 30.seconds }
|
|
33
|
-
)
|
|
24
|
+
matchers = [ "alert_severity!=\"\"", "environment=\"#{Rails.env}\"" ]
|
|
25
|
+
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
26
|
+
"{#{matchers.join(",")}}"
|
|
34
27
|
end
|
|
35
28
|
end
|
|
36
29
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class Upright::Rollups::DailyUptime
|
|
2
|
+
attr_reader :day
|
|
3
|
+
|
|
4
|
+
def initialize(day, sites: Upright.sites.select(&:stores_metrics?))
|
|
5
|
+
@day, @sites = day, sites
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def covered
|
|
9
|
+
probe_uptimes.select(&:covered?)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def gappy
|
|
13
|
+
probe_uptimes.reject(&:covered?)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
def probe_uptimes
|
|
18
|
+
@probe_uptimes ||= best_covered_per_probe(reported_uptimes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def best_covered_per_probe(probe_uptimes)
|
|
22
|
+
probe_uptimes.group_by(&:probe_key).values.map { |reported| reported.max_by(&:coverage_fraction) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reported_uptimes
|
|
26
|
+
@sites.flat_map { |site| Upright::Rollups::SiteUptime.new(site, on: day).probe_uptimes }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
class Upright::Rollups::ProbeRollup < Upright::PersistentRecord
|
|
2
|
+
self.table_name = "upright_rollups_probe_rollups"
|
|
3
|
+
|
|
4
|
+
enum :status, Upright::Status::VALUES, default: :operational
|
|
5
|
+
|
|
6
|
+
before_save :derive_status_from_uptime
|
|
7
|
+
|
|
8
|
+
scope :for_period, ->(range) { where(period_start: range) }
|
|
9
|
+
scope :for_service, ->(code) { where(probe_service: code) if code.present? }
|
|
10
|
+
scope :for_probe, ->(name) { where(probe_name: name) if name.present? }
|
|
11
|
+
|
|
12
|
+
def self.rollup_day(day)
|
|
13
|
+
uptime = Upright::Rollups::DailyUptime.new(day)
|
|
14
|
+
|
|
15
|
+
uptime.covered.each { |probe_uptime| record probe_uptime, on: day }
|
|
16
|
+
|
|
17
|
+
uptime.gappy.tap do |left_unwritten|
|
|
18
|
+
Rails.logger.warn thin_coverage_warning(left_unwritten, on: day) if left_unwritten.any?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.thin_coverage_warning(probe_uptimes, on:)
|
|
23
|
+
"[upright] #{on}: left #{probe_uptimes.size} rollups unwritten below #{Upright.configuration.rollup_minimum_coverage} coverage (#{probe_uptimes.map(&:probe_name).uniq.join(', ')})"
|
|
24
|
+
end
|
|
25
|
+
private_class_method :thin_coverage_warning
|
|
26
|
+
|
|
27
|
+
def self.record(probe_uptime, on:)
|
|
28
|
+
rollup = find_or_initialize_by(
|
|
29
|
+
probe_name: probe_uptime.probe_name,
|
|
30
|
+
probe_type: probe_uptime.probe_type,
|
|
31
|
+
probe_target: probe_uptime.probe_target,
|
|
32
|
+
period_start: on.beginning_of_day
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
rollup.probe_service = probe_uptime.probe_service
|
|
36
|
+
rollup.uptime_fraction = probe_uptime.uptime_fraction
|
|
37
|
+
rollup.save!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.export_metrics
|
|
41
|
+
if (last_run = maximum(:created_at))
|
|
42
|
+
Yabeda.upright_rollup_last_run_timestamp_seconds.set({}, last_run.to_i)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def probe_key
|
|
47
|
+
[ probe_name, probe_type, probe_target ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def service
|
|
51
|
+
Upright::Service.find_by(code: probe_service) if probe_service.present?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def uptime_percentage
|
|
55
|
+
if uptime_fraction.present?
|
|
56
|
+
(uptime_fraction * 100).round(4)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
def derive_status_from_uptime
|
|
62
|
+
self.status = Upright::Status.for(uptime_fraction)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class Upright::Rollups::ProbeUptime < Data.define(:probe_name, :probe_type, :probe_target, :probe_service, :uptime_fraction, :coverage_fraction)
|
|
2
|
+
def probe_key
|
|
3
|
+
[ probe_name, probe_type, probe_target ]
|
|
4
|
+
end
|
|
5
|
+
|
|
6
|
+
def covered?
|
|
7
|
+
coverage_fraction >= Upright.configuration.rollup_minimum_coverage
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
class Upright::Rollups::SiteUptime
|
|
2
|
+
UPTIME_METRIC = "upright:probe_uptime_daily"
|
|
3
|
+
COVERAGE_METRIC = "upright:probe_down_fraction"
|
|
4
|
+
|
|
5
|
+
attr_reader :site, :day
|
|
6
|
+
|
|
7
|
+
def initialize(site, on:)
|
|
8
|
+
@site, @day = site, on
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def probe_uptimes
|
|
12
|
+
uptime_series.map do |metric, uptime|
|
|
13
|
+
Upright::Rollups::ProbeUptime.new(
|
|
14
|
+
probe_name: metric[:name],
|
|
15
|
+
probe_type: metric[:type],
|
|
16
|
+
probe_target: metric[:probe_target],
|
|
17
|
+
probe_service: metric[:probe_service].presence,
|
|
18
|
+
uptime_fraction: uptime,
|
|
19
|
+
coverage_fraction: coverage_for(metric)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
rescue StandardError => error
|
|
23
|
+
nothing_reported because: error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
def nothing_reported(because:)
|
|
28
|
+
Rails.logger.warn "[upright] #{site.code} Prometheus unavailable for the #{day} rollup: #{because.class}: #{because.message}"
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def uptime_series
|
|
33
|
+
series_for uptime_query
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def coverage_for(metric)
|
|
37
|
+
[ coverage.fetch(probe_key_for(metric), 0.0), 1.0 ].min
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def coverage
|
|
41
|
+
@coverage ||= series_for(coverage_query).to_h do |metric, samples|
|
|
42
|
+
[ probe_key_for(metric), samples / expected_samples ]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def series_for(query)
|
|
47
|
+
values_by_metric site.prometheus_client.query(query: query, time: queried_at.iso8601)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def values_by_metric(response)
|
|
51
|
+
Array(response.deep_symbolize_keys[:result]).map do |series|
|
|
52
|
+
[ series[:metric], series.dig(:value, 1).to_f ]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def queried_at
|
|
57
|
+
[ day.end_of_day, Time.current ].min
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def expected_samples
|
|
61
|
+
1.day / Upright.configuration.rollup_evaluation_interval
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def probe_key_for(metric)
|
|
65
|
+
metric.values_at(:name, :type, :probe_target)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def uptime_query
|
|
69
|
+
%(#{UPTIME_METRIC}{environment="#{Rails.env}"})
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def coverage_query
|
|
73
|
+
%(count_over_time(#{COVERAGE_METRIC}{environment="#{Rails.env}"}[1d]))
|
|
74
|
+
end
|
|
75
|
+
end
|