upright 0.3.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 +66 -7
- 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/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/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 +36 -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 +1 -0
- 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 -2
- 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 +26 -2
- data/app/models/concerns/upright/probeable.rb +9 -1
- 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 +12 -3
- 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/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 +3 -11
- data/app/models/upright/probes/uptime.rb +2 -9
- 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/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/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/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 +18 -10
- 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/omniauth.rb +7 -3
- data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
- data/lib/generators/upright/install/templates/recurring.yml +20 -0
- data/lib/generators/upright/install/templates/sites.yml +4 -0
- data/lib/generators/upright/install/templates/upright.rb +21 -2
- data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
- data/lib/upright/configuration.rb +113 -6
- data/lib/upright/engine.rb +25 -2
- data/lib/upright/metrics.rb +25 -0
- data/lib/upright/site.rb +31 -1
- data/lib/upright/version.rb +3 -1
- data/lib/upright.rb +29 -2
- metadata +107 -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,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
|
|
@@ -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
|
-
matchers = [ "alert_severity!=\"\"" ]
|
|
20
|
+
matchers = [ "alert_severity!=\"\"", "environment=\"#{Rails.env}\"" ]
|
|
21
21
|
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
22
22
|
"{#{matchers.join(",")}}"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def prometheus_client
|
|
26
|
-
Prometheus::ApiClient.client(
|
|
27
|
-
url: ENV.fetch("PROMETHEUS_URL", "http://localhost:9090"),
|
|
28
|
-
options: { timeout: 30.seconds }
|
|
29
|
-
)
|
|
30
|
-
end
|
|
31
|
-
|
|
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,
|
|
@@ -21,16 +21,9 @@ class Upright::Probes::Uptime
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def label_selector(probe_type)
|
|
24
|
-
matchers = [ "alert_severity!=\"\"" ]
|
|
24
|
+
matchers = [ "alert_severity!=\"\"", "environment=\"#{Rails.env}\"" ]
|
|
25
25
|
matchers << "type=\"#{probe_type}\"" if probe_type.present?
|
|
26
26
|
"{#{matchers.join(",")}}"
|
|
27
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
|
-
)
|
|
34
|
-
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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
class Upright::Service::DailyStatus
|
|
2
|
+
attr_reader :date, :status, :uptime_fraction
|
|
3
|
+
|
|
4
|
+
def initialize(date:, status: nil, uptime_fraction: nil)
|
|
5
|
+
@date = date
|
|
6
|
+
@status = status
|
|
7
|
+
@uptime_fraction = uptime_fraction
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def operational?
|
|
11
|
+
status == :operational
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def date_label
|
|
15
|
+
date == Date.current ? "Today" : date.to_fs(:month_day)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def detail
|
|
19
|
+
if uptime_fraction
|
|
20
|
+
[ "%.2f%% uptime" % (uptime_fraction * 100), downtime ].compact.join(" · ")
|
|
21
|
+
elsif status
|
|
22
|
+
status.to_s.humanize.downcase
|
|
23
|
+
else
|
|
24
|
+
"no data"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def aria_label
|
|
29
|
+
"#{date_label}: #{detail}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
def downtime
|
|
34
|
+
if uptime_fraction && uptime_fraction < 1
|
|
35
|
+
minutes = ((1 - uptime_fraction) * 24 * 60).round
|
|
36
|
+
minutes.zero? ? "<1 min down" : "#{minutes} #{'min'.pluralize(minutes)} down"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class Upright::Service::IncidentHistory
|
|
2
|
+
attr_reader :service, :page
|
|
3
|
+
|
|
4
|
+
delegate :daily_status_history, :maintenance_active?, :name, to: :service
|
|
5
|
+
|
|
6
|
+
def self.for(service, page: nil)
|
|
7
|
+
new service, page: GearedPagination::Recordset.new(
|
|
8
|
+
service.incidents.past.reorder(nil).preload(:updates), ordered_by: { starts_at: :desc }
|
|
9
|
+
).page(page)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(service, page:)
|
|
13
|
+
@service = service
|
|
14
|
+
@page = page
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def active_incidents
|
|
18
|
+
@active_incidents ||= service.incidents.reactive.active.preload(:updates)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def past_incidents
|
|
22
|
+
page.records
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class Upright::Service::Outage < Data.define(:service, :status, :started_at)
|
|
2
|
+
IMPACT_BY_STATUS = {
|
|
3
|
+
degraded: "minor",
|
|
4
|
+
partial_outage: "major",
|
|
5
|
+
major_outage: "critical"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
def impact
|
|
9
|
+
IMPACT_BY_STATUS.fetch(status)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def started_at_or_lookback(observed_at:)
|
|
13
|
+
started_at || observed_at - Upright::Services::LiveStatus::OUTAGE_LOOKBACK
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
class Upright::Service::StatusPage
|
|
2
|
+
attr_reader :services, :active_incidents, :active_maintenances, :upcoming_maintenances
|
|
3
|
+
|
|
4
|
+
def self.current
|
|
5
|
+
services = Upright::Service.public_facing
|
|
6
|
+
|
|
7
|
+
new services: services,
|
|
8
|
+
active_incidents: Upright::Incident.public_facing.reactive.active.order(starts_at: :desc),
|
|
9
|
+
active_maintenances: Upright::Maintenance.public_facing.active.order(:starts_at),
|
|
10
|
+
upcoming_maintenances: Upright::Maintenance.public_facing.upcoming.order(:starts_at)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(services:, active_incidents:, active_maintenances:, upcoming_maintenances:)
|
|
14
|
+
@services = services
|
|
15
|
+
@active_incidents = active_incidents
|
|
16
|
+
@active_maintenances = active_maintenances
|
|
17
|
+
@upcoming_maintenances = upcoming_maintenances
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def overall_status
|
|
21
|
+
services.overall_status(incidents: Upright::Incident.public_facing)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def outages
|
|
25
|
+
services.degraded
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def active_events
|
|
29
|
+
active_incidents.to_a + active_maintenances.to_a
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def service_histories
|
|
33
|
+
services.by_history
|
|
34
|
+
end
|
|
35
|
+
end
|