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,93 @@
|
|
|
1
|
+
module Upright::Incidents::AutoReporting
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
RESOLVE_DELAY = 5.minutes
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
scope :auto_created, -> { where(auto_created: true) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def report_downtime(now: Time.current)
|
|
12
|
+
outages = Upright::Service.degraded
|
|
13
|
+
|
|
14
|
+
outages.each { |outage| report_outage(outage, observed_at: now) }
|
|
15
|
+
observe_recoveries(excluding: outages.map(&:service), observed_at: now)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
def report_outage(outage, observed_at:)
|
|
20
|
+
if incident = reactive.unresolved.for_service(outage.service.code).first
|
|
21
|
+
incident.observe_downtime(outage, at: observed_at)
|
|
22
|
+
else
|
|
23
|
+
open_automatically(outage, at: observed_at)
|
|
24
|
+
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
joins(:automatic_report)
|
|
27
|
+
.find_by!(upright_incident_automatic_reports: { service_code: outage.service.code })
|
|
28
|
+
.observe_downtime(outage, at: observed_at)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def open_automatically(outage, at:)
|
|
32
|
+
new(
|
|
33
|
+
title: "#{outage.service.name} outage",
|
|
34
|
+
impact: outage.impact,
|
|
35
|
+
starts_at: outage.started_at_or_lookback(observed_at: at),
|
|
36
|
+
last_seen_down_at: at,
|
|
37
|
+
created_by: "System"
|
|
38
|
+
).tap do |incident|
|
|
39
|
+
incident.automatically_affect(outage.service)
|
|
40
|
+
incident.body = incident.update_body_for(:down)
|
|
41
|
+
incident.save!
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def observe_recoveries(excluding:, observed_at:)
|
|
46
|
+
degraded_codes = excluding.map(&:code)
|
|
47
|
+
|
|
48
|
+
auto_created.reactive.unresolved.find_each do |incident|
|
|
49
|
+
incident.observe_recovery(at: observed_at) unless (incident.service_codes & degraded_codes).any?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def automatically_affect(service)
|
|
55
|
+
self.auto_created = true
|
|
56
|
+
self.service_codes = [ service.code ]
|
|
57
|
+
build_automatic_report(service_code: service.code)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def observe_downtime(outage, at:)
|
|
61
|
+
self.last_seen_down_at = at
|
|
62
|
+
self.recovery_started_at = nil
|
|
63
|
+
escalate_to(outage.impact)
|
|
64
|
+
save!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def observe_recovery(at:)
|
|
68
|
+
if services.any?(&:maintenance_active?)
|
|
69
|
+
pause_recovery
|
|
70
|
+
elsif recovery_started_at.nil?
|
|
71
|
+
update!(recovery_started_at: at)
|
|
72
|
+
elsif recovery_started_at <= at - RESOLVE_DELAY
|
|
73
|
+
resolve_automatically(at: at)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
def escalate_to(new_impact)
|
|
79
|
+
self.impact = new_impact if self.class::IMPACTS.index(new_impact) > self.class::IMPACTS.index(impact)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pause_recovery
|
|
83
|
+
update!(recovery_started_at: nil) if recovery_started_at?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_automatically(at:)
|
|
87
|
+
record_update({ status: "resolved", body: update_body_for(:back_up) }, recorded_at: at)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def after_recording_terminal_status
|
|
91
|
+
automatic_report&.destroy!
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Upright::Incidents::Lifecycle
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
scope :resolved, -> { where.not(resolved_at: nil) }
|
|
6
|
+
scope :unresolved, -> { where(resolved_at: nil) }
|
|
7
|
+
|
|
8
|
+
scope :active, -> { unresolved.where(starts_at: ..Time.current) }
|
|
9
|
+
scope :upcoming, -> { unresolved.where(starts_at: Time.current..) }
|
|
10
|
+
scope :past, -> { resolved.order(starts_at: :desc) }
|
|
11
|
+
|
|
12
|
+
scope :reactive, -> { where.not(type: "Upright::Maintenance").or(where(type: nil)) }
|
|
13
|
+
scope :planned, -> { where(type: "Upright::Maintenance") }
|
|
14
|
+
|
|
15
|
+
scope :for_service, ->(code) {
|
|
16
|
+
joins(:affected_services).where(upright_incident_affected_services: { service_code: code })
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def active? = resolved_at.nil? && starts_at <= Time.current
|
|
21
|
+
def upcoming? = resolved_at.nil? && starts_at > Time.current
|
|
22
|
+
def past? = resolved_at.present?
|
|
23
|
+
|
|
24
|
+
def record_update(attributes = nil, recorded_at: Time.current, **update_attributes)
|
|
25
|
+
attributes = attributes ? attributes.merge(update_attributes) : update_attributes
|
|
26
|
+
|
|
27
|
+
updates.build(attributes.merge(created_at: recorded_at)).tap do |update|
|
|
28
|
+
next unless update.valid?
|
|
29
|
+
|
|
30
|
+
self.status = update.status
|
|
31
|
+
if self.class::TERMINAL_STATUSES.include?(update.status)
|
|
32
|
+
self.resolved_at ||= recorded_at
|
|
33
|
+
end
|
|
34
|
+
next unless valid?
|
|
35
|
+
|
|
36
|
+
transaction do
|
|
37
|
+
update.save
|
|
38
|
+
save
|
|
39
|
+
after_recording_terminal_status if self.class::TERMINAL_STATUSES.include?(update.status)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
def after_recording_terminal_status
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -3,6 +3,7 @@ module Upright::Playwright::FormAuthentication
|
|
|
3
3
|
|
|
4
4
|
included do
|
|
5
5
|
class_attribute :authentication_service
|
|
6
|
+
set_callback :page_ready, :after, :ensure_authenticated
|
|
6
7
|
end
|
|
7
8
|
|
|
8
9
|
class_methods do
|
|
@@ -12,9 +13,9 @@ module Upright::Playwright::FormAuthentication
|
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
private
|
|
15
|
-
def
|
|
16
|
+
def ensure_authenticated
|
|
16
17
|
if authentication_service
|
|
17
|
-
authenticator_for(authentication_service).new(
|
|
18
|
+
authenticator_for(authentication_service).new.ensure_authenticated(context, page)
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
|
|
@@ -16,14 +16,8 @@ module Upright::Playwright::Lifecycle
|
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
def with_browser(&block)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
playwright.chromium.launch(headless: false, &block)
|
|
22
|
-
end
|
|
23
|
-
else
|
|
24
|
-
server_url = Upright.configuration.playwright_server_url ||
|
|
25
|
-
Rails.application.config_for(:playwright).fetch(:server_url)
|
|
26
|
-
::Playwright.connect_to_browser_server(server_url, &block)
|
|
19
|
+
::Playwright.create(playwright_cli_executable_path: Upright.configuration.playwright_cli_path) do |playwright|
|
|
20
|
+
playwright.chromium.launch(headless: ENV.fetch("HEADLESS", "true") != "false", &block)
|
|
27
21
|
end
|
|
28
22
|
end
|
|
29
23
|
|
|
@@ -33,13 +27,14 @@ module Upright::Playwright::Lifecycle
|
|
|
33
27
|
run_callbacks :page_ready
|
|
34
28
|
yield
|
|
35
29
|
ensure
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
run_callbacks :page_close do
|
|
31
|
+
# Rescue each step independently so one failed close does not block artifact capture
|
|
32
|
+
page&.close rescue Rails.error.report($!)
|
|
33
|
+
context&.close rescue Rails.error.report($!)
|
|
34
|
+
end
|
|
40
35
|
end
|
|
41
36
|
|
|
42
37
|
def create_context(browser, **options)
|
|
43
|
-
|
|
38
|
+
browser.new_context(userAgent: user_agent, **options)
|
|
44
39
|
end
|
|
45
40
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Upright::Playwright::TraceRecording
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
attr_accessor :trace_artifacts
|
|
6
|
+
|
|
7
|
+
set_callback :page_ready, :before, :start_trace
|
|
8
|
+
set_callback :page_close, :before, :stop_trace
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
def trace_dir
|
|
13
|
+
Upright.configuration.recording_base_dir
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def start_trace
|
|
17
|
+
# Authenticated probes are not traced at all: a trace records network
|
|
18
|
+
# activity, which for an authenticated context includes Cookie and
|
|
19
|
+
# Authorization headers. Unauthenticated probes carry no credentials, so
|
|
20
|
+
# they still get a screenshots-only trace with snapshots (DOM + network
|
|
21
|
+
# payloads) disabled.
|
|
22
|
+
return if authentication_service.present?
|
|
23
|
+
|
|
24
|
+
context.tracing.start(screenshots: true, snapshots: false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def stop_trace
|
|
28
|
+
return if authentication_service.present?
|
|
29
|
+
|
|
30
|
+
trace_path = trace_dir.join("#{SecureRandom.hex}.zip").to_s
|
|
31
|
+
FileUtils.mkdir_p(trace_dir)
|
|
32
|
+
context.tracing.stop(path: trace_path)
|
|
33
|
+
|
|
34
|
+
self.trace_artifacts ||= []
|
|
35
|
+
trace_artifacts << { label: current_recording_label, path: trace_path }
|
|
36
|
+
rescue => error
|
|
37
|
+
Rails.error.report(error)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def attach_trace(probe_result)
|
|
41
|
+
Array(trace_artifacts).each do |artifact|
|
|
42
|
+
next unless File.exist?(artifact.fetch(:path))
|
|
43
|
+
|
|
44
|
+
File.open(artifact.fetch(:path), "rb") do |file|
|
|
45
|
+
Upright::Artifact.new(name: recording_artifact_filename(artifact[:label], "zip"), content: file).attach_to(probe_result, timestamped: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
FileUtils.rm(artifact.fetch(:path))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
self.trace_artifacts = []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -4,7 +4,7 @@ module Upright::Playwright::VideoRecording
|
|
|
4
4
|
VIDEO_SIZE = { width: 1280, height: 720 }
|
|
5
5
|
|
|
6
6
|
included do
|
|
7
|
-
attr_accessor :
|
|
7
|
+
attr_accessor :video_artifacts, :pending_video_recording
|
|
8
8
|
|
|
9
9
|
set_callback :page_ready, :after, :capture_video_reference
|
|
10
10
|
set_callback :page_close, :after, :finalize_video
|
|
@@ -33,28 +33,69 @@ module Upright::Playwright::VideoRecording
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def capture_video_reference
|
|
36
|
-
|
|
36
|
+
return unless record_video?
|
|
37
|
+
return unless (video = page.video)
|
|
38
|
+
|
|
39
|
+
self.pending_video_recording = { label: current_recording_label, video: video }
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def save_video
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
return unless pending_video_recording
|
|
44
|
+
|
|
45
|
+
video_path = video_dir.join("#{SecureRandom.hex}.webm").to_s
|
|
46
|
+
pending_video_recording.fetch(:video).save_as(video_path)
|
|
47
|
+
|
|
48
|
+
self.video_artifacts ||= []
|
|
49
|
+
video_artifacts << pending_video_recording.merge(path: video_path)
|
|
50
|
+
ensure
|
|
51
|
+
delete_source_video
|
|
52
|
+
self.pending_video_recording = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Playwright writes its own source file per page; delete it after saving
|
|
56
|
+
# our copy so page@*.webm recordings don't accumulate on disk.
|
|
57
|
+
def delete_source_video
|
|
58
|
+
pending_video_recording&.fetch(:video)&.delete
|
|
59
|
+
rescue => error
|
|
60
|
+
Rails.error.report(error)
|
|
44
61
|
end
|
|
45
62
|
|
|
46
63
|
def attach_video(probe_result)
|
|
47
|
-
|
|
48
|
-
File.
|
|
49
|
-
Upright::Artifact.new(name: "#{probe_name}.webm", content: file).attach_to(probe_result, timestamped: true)
|
|
50
|
-
end
|
|
64
|
+
Array(video_artifacts).each do |artifact|
|
|
65
|
+
next unless File.exist?(artifact.fetch(:path))
|
|
51
66
|
|
|
52
|
-
|
|
53
|
-
|
|
67
|
+
File.open(artifact.fetch(:path), "rb") do |file|
|
|
68
|
+
Upright::Artifact.new(name: recording_artifact_filename(artifact[:label], "webm"), content: file).attach_to(probe_result, timestamped: true)
|
|
54
69
|
end
|
|
55
70
|
|
|
56
|
-
FileUtils.rm(
|
|
57
|
-
|
|
71
|
+
FileUtils.rm(artifact.fetch(:path))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if logger.respond_to?(:struct)
|
|
75
|
+
video_artifact = probe_result.artifacts.find { |attached| attached.content_type == "video/webm" }
|
|
76
|
+
if video_artifact
|
|
77
|
+
# Log the blob key, never a signed URL: signed URLs in logs/OTEL are
|
|
78
|
+
# bearer credentials for the recording.
|
|
79
|
+
logger.struct probe_artifact_key: video_artifact.key
|
|
80
|
+
end
|
|
58
81
|
end
|
|
82
|
+
|
|
83
|
+
self.video_artifacts = []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def recording_artifact_filename(label, extension)
|
|
87
|
+
[ recording_artifact_basename(label), extension ].join(".")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recording_artifact_basename(label)
|
|
91
|
+
[ artifact_recording_name, label ].compact.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def artifact_recording_name
|
|
95
|
+
respond_to?(:probe_name) ? probe_name : service_name.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def current_recording_label
|
|
99
|
+
nil
|
|
59
100
|
end
|
|
60
101
|
end
|
|
@@ -8,7 +8,7 @@ module Upright::ProbeResult::StaleCleanup
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def cleanup_stale_successes
|
|
11
|
-
ok.where(created_at: ...Upright.config.stale_success_threshold.ago).
|
|
11
|
+
ok.where(created_at: ...Upright.config.stale_success_threshold.ago).purge_in_batches
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def cleanup_stale_failures
|
|
@@ -17,7 +17,31 @@ module Upright::ProbeResult::StaleCleanup
|
|
|
17
17
|
fail.order(created_at: :desc).offset(Upright.config.failure_retention_limit).pick(:created_at)
|
|
18
18
|
].compact.max
|
|
19
19
|
|
|
20
|
-
fail.where(created_at: ..cutoff).
|
|
20
|
+
fail.where(created_at: ..cutoff).purge_in_batches
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Delete records and their artifacts without going through destroy_all's
|
|
24
|
+
# per-record callback cascade. has_many_attached's default dependent is
|
|
25
|
+
# :purge_later, which enqueues one ActiveStorage::PurgeJob per attachment
|
|
26
|
+
# — at ~13k attachments per hourly run on a busy site, that floods the
|
|
27
|
+
# queue DB enough to contend with probe writes. Here we issue batched
|
|
28
|
+
# DELETEs for the records and their attachment rows, then purge blobs
|
|
29
|
+
# inline so no jobs are enqueued.
|
|
30
|
+
def purge_in_batches
|
|
31
|
+
in_batches do |batch|
|
|
32
|
+
batch_ids = batch.pluck(:id)
|
|
33
|
+
attachments = ActiveStorage::Attachment.where(record_type: name, record_id: batch_ids)
|
|
34
|
+
blob_ids = attachments.distinct.pluck(:blob_id)
|
|
35
|
+
|
|
36
|
+
attachments.delete_all
|
|
37
|
+
batch.delete_all
|
|
38
|
+
|
|
39
|
+
ActiveStorage::Blob.unattached.where(id: blob_ids).find_each do |blob|
|
|
40
|
+
blob.purge
|
|
41
|
+
rescue => e
|
|
42
|
+
Rails.error.report(e)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
21
45
|
end
|
|
22
46
|
end
|
|
23
47
|
end
|
|
@@ -4,12 +4,16 @@ module Upright::Probeable
|
|
|
4
4
|
|
|
5
5
|
ALERT_SEVERITIES = %i[ medium high critical ]
|
|
6
6
|
|
|
7
|
+
mattr_accessor :probe_classes, default: []
|
|
8
|
+
|
|
7
9
|
included do
|
|
8
10
|
attr_writer :logger
|
|
9
11
|
|
|
10
12
|
def logger
|
|
11
13
|
@logger || Rails.logger
|
|
12
14
|
end
|
|
15
|
+
|
|
16
|
+
Upright::Probeable.probe_classes |= [ self ]
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
class_methods do
|
|
@@ -61,7 +65,11 @@ module Upright::Probeable
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def probe_service
|
|
64
|
-
|
|
68
|
+
try(:service)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def probe_key
|
|
72
|
+
[ probe_name, probe_type, probe_target ]
|
|
65
73
|
end
|
|
66
74
|
|
|
67
75
|
def probe_alert_severity
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Upright::Services::LiveStatus
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
OUTAGE_LOOKBACK = 24.hours
|
|
5
|
+
|
|
6
|
+
# Matches the public status page's `expires_in`, so a burst of anonymous
|
|
7
|
+
# hits costs at most one Prometheus round trip per service per window
|
|
8
|
+
# instead of amplifying every request into live queries.
|
|
9
|
+
CACHE_TTL = 15.seconds
|
|
10
|
+
|
|
11
|
+
def live_status
|
|
12
|
+
Upright::Status.for(live_up_fraction)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Earliest moment of the current outage, or nil if the service is currently
|
|
16
|
+
# clear OR the outage predates OUTAGE_LOOKBACK. Callers should treat nil on a
|
|
17
|
+
# non-operational service as "longer than the live window."
|
|
18
|
+
def current_outage_started_at(now: Time.current)
|
|
19
|
+
history = live_down_history(now: now)
|
|
20
|
+
last_clear = history.rindex { |_ts, value| value.to_f == 0 }
|
|
21
|
+
|
|
22
|
+
if last_clear && last_clear < history.length - 1
|
|
23
|
+
Time.zone.at(history[last_clear + 1].first.to_f)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def live_up_fraction
|
|
29
|
+
1 - live_down_fraction
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def live_down_fraction
|
|
33
|
+
cached :live_down_fraction do
|
|
34
|
+
response = Upright.prometheus_client.query(
|
|
35
|
+
query: live_down_query
|
|
36
|
+
).deep_symbolize_keys
|
|
37
|
+
response.dig(:result, 0, :value, 1).to_f
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Cached without regard to `now:` — a window up to CACHE_TTL stale is
|
|
42
|
+
# within the freshness the status page already advertises.
|
|
43
|
+
def live_down_history(now:)
|
|
44
|
+
cached :live_down_history do
|
|
45
|
+
response = Upright.prometheus_client.query_range(
|
|
46
|
+
query: live_down_query,
|
|
47
|
+
start: (now - OUTAGE_LOOKBACK).iso8601,
|
|
48
|
+
end: now.iso8601,
|
|
49
|
+
step: "300s"
|
|
50
|
+
).deep_symbolize_keys
|
|
51
|
+
response.dig(:result, 0, :values) || []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def cached(name, &block)
|
|
56
|
+
Rails.cache.fetch([ "upright", name, code ], expires_in: CACHE_TTL, &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def live_down_query
|
|
60
|
+
matchers = [ %(probe_service="#{code}"), %(environment="#{Rails.env}"), %(type=~"#{uptime_probe_types_pattern}") ]
|
|
61
|
+
%(max(upright:probe_down_fraction{#{matchers.join(",")}}) or vector(0))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Each type escaped as a regex literal, then the pattern escaped again for
|
|
65
|
+
# the PromQL string it sits in, so "api.v2" doesn't also match "apiXv2".
|
|
66
|
+
def uptime_probe_types_pattern
|
|
67
|
+
uptime_probe_types.map { |type| Regexp.escape(type) }.join("|").gsub(/["\\]/) { |char| "\\#{char}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Upright::Services::MaintenanceStatus
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
def active_maintenance
|
|
5
|
+
Upright::Maintenance.active.for_service(code).first
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def maintenance_active?
|
|
9
|
+
active_maintenance.present?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def upcoming_maintenances
|
|
13
|
+
Upright::Maintenance.upcoming.for_service(code).order(:starts_at)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def active_incidents
|
|
17
|
+
Upright::Incident.reactive.active.for_service(code)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
class Upright::HTTP::Request
|
|
2
|
+
# Verbose curl output echoes request/response headers and credential lines.
|
|
3
|
+
# Redact their values before the log is attached as a viewable artifact.
|
|
4
|
+
SENSITIVE_HEADER = /^(\s*(?:authorization|proxy-authorization|cookie|set-cookie)\s*:\s*)[^\r\n]+/i
|
|
5
|
+
SENSITIVE_CREDENTIAL = /^(\s*[^\r\n:=]*(?:userpwd|password|credential)[^\r\n:=]*\s*[:=]\s*)[^\r\n]+/i
|
|
6
|
+
|
|
2
7
|
attr_reader :url, :options
|
|
3
8
|
|
|
4
9
|
def initialize(url, **options)
|
|
@@ -50,10 +55,14 @@ class Upright::HTTP::Request
|
|
|
50
55
|
def build_log(response)
|
|
51
56
|
StringIO.new.tap do |log|
|
|
52
57
|
if response.debug_info
|
|
53
|
-
response.debug_info.text.each { |msg| log.puts "* #{msg.chomp}" }
|
|
54
|
-
response.debug_info.header_out.each { |msg| log.puts "> #{msg.chomp}" }
|
|
55
|
-
response.debug_info.header_in.each { |msg| log.puts "< #{msg.chomp}" }
|
|
58
|
+
response.debug_info.text.each { |msg| log.puts "* #{redact(msg.chomp)}" }
|
|
59
|
+
response.debug_info.header_out.each { |msg| log.puts "> #{redact(msg.chomp)}" }
|
|
60
|
+
response.debug_info.header_in.each { |msg| log.puts "< #{redact(msg.chomp)}" }
|
|
56
61
|
end
|
|
57
62
|
end
|
|
58
63
|
end
|
|
64
|
+
|
|
65
|
+
def redact(message)
|
|
66
|
+
message.scrub.gsub(SENSITIVE_HEADER, '\1[REDACTED]').gsub(SENSITIVE_CREDENTIAL, '\1[REDACTED]')
|
|
67
|
+
end
|
|
59
68
|
end
|
|
@@ -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
|