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,14 @@
|
|
|
1
|
+
class Upright::Rollups::DailyAggregationJob < Upright::ApplicationJob
|
|
2
|
+
queue_as :default
|
|
3
|
+
|
|
4
|
+
# Aggregates daily rollups for completed days only — today is still in progress
|
|
5
|
+
# and is represented live by Service#live_status, so persisting a half-day
|
|
6
|
+
# rollup would just produce a stale value the rest of the day.
|
|
7
|
+
def perform(past: 7.days)
|
|
8
|
+
left_unwritten = (past.ago.to_date..Date.yesterday).flat_map do |day|
|
|
9
|
+
Upright::Rollups::ProbeRollup.rollup_day(day)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
Yabeda.upright_rollup_skipped_probes.set({}, left_unwritten.size)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
File without changes
|
|
@@ -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,16 +13,13 @@ 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
|
|
|
21
22
|
def authenticator_for(service)
|
|
22
|
-
# First try the host app's authenticator, then fall back to engine's
|
|
23
23
|
"::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
24
|
-
rescue NameError
|
|
25
|
-
"Upright::Playwright::Authenticator::#{service.to_s.camelize}".constantize
|
|
26
24
|
end
|
|
27
25
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Upright::ProbeResult::StaleCleanup
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
class_methods do
|
|
5
|
+
def cleanup_stale
|
|
6
|
+
cleanup_stale_successes
|
|
7
|
+
cleanup_stale_failures
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def cleanup_stale_successes
|
|
11
|
+
ok.where(created_at: ...Upright.config.stale_success_threshold.ago).purge_in_batches
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def cleanup_stale_failures
|
|
15
|
+
cutoff = [
|
|
16
|
+
Upright.config.stale_failure_threshold.ago,
|
|
17
|
+
fail.order(created_at: :desc).offset(Upright.config.failure_retention_limit).pick(:created_at)
|
|
18
|
+
].compact.max
|
|
19
|
+
|
|
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
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -2,7 +2,9 @@ module Upright::Probeable
|
|
|
2
2
|
extend ActiveSupport::Concern
|
|
3
3
|
include Upright::Staggerable
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
ALERT_SEVERITIES = %i[ medium high critical ]
|
|
6
|
+
|
|
7
|
+
mattr_accessor :probe_classes, default: []
|
|
6
8
|
|
|
7
9
|
included do
|
|
8
10
|
attr_writer :logger
|
|
@@ -10,6 +12,8 @@ module Upright::Probeable
|
|
|
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
|
|
@@ -35,6 +39,7 @@ module Upright::Probeable
|
|
|
35
39
|
probe_name: probe_name,
|
|
36
40
|
probe_target: probe_target,
|
|
37
41
|
probe_service: probe_service,
|
|
42
|
+
probe_alert_severity: probe_alert_severity,
|
|
38
43
|
status: result[:status],
|
|
39
44
|
duration: result[:duration],
|
|
40
45
|
error: result[:error]
|
|
@@ -60,7 +65,16 @@ module Upright::Probeable
|
|
|
60
65
|
end
|
|
61
66
|
|
|
62
67
|
def probe_service
|
|
63
|
-
|
|
68
|
+
try(:service)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def probe_key
|
|
72
|
+
[ probe_name, probe_type, probe_target ]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def probe_alert_severity
|
|
76
|
+
severity = try(:alert_severity)&.to_sym
|
|
77
|
+
ALERT_SEVERITIES.include?(severity) ? severity : :high
|
|
64
78
|
end
|
|
65
79
|
|
|
66
80
|
private
|
|
@@ -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)
|
|
@@ -23,7 +28,7 @@ class Upright::HTTP::Request
|
|
|
23
28
|
proxy: proxy_url,
|
|
24
29
|
proxyuserpwd: proxy_userpwd,
|
|
25
30
|
verbose: true,
|
|
26
|
-
forbid_reuse:
|
|
31
|
+
forbid_reuse: proxy_url.nil?
|
|
27
32
|
}.compact
|
|
28
33
|
end
|
|
29
34
|
|
|
@@ -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
|