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.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +208 -56
  3. data/Rakefile +22 -0
  4. data/app/assets/images/upright/.keep +0 -0
  5. data/app/assets/stylesheets/upright/_global.css +2 -0
  6. data/app/assets/stylesheets/upright/artifact.css +7 -25
  7. data/app/assets/stylesheets/upright/flash.css +19 -0
  8. data/app/assets/stylesheets/upright/header.css +14 -0
  9. data/app/assets/stylesheets/upright/incidents.css +610 -0
  10. data/app/assets/stylesheets/upright/map.css +6 -0
  11. data/app/assets/stylesheets/upright/public_status.css +534 -0
  12. data/app/assets/stylesheets/upright/uptime-bars.css +10 -1
  13. data/app/controllers/concerns/.keep +0 -0
  14. data/app/controllers/concerns/upright/authentication.rb +1 -0
  15. data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
  16. data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
  17. data/app/controllers/upright/artifacts_controller.rb +1 -1
  18. data/app/controllers/upright/incidents/updates_controller.rb +13 -0
  19. data/app/controllers/upright/incidents_controller.rb +53 -0
  20. data/app/controllers/upright/probe_results_controller.rb +1 -0
  21. data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
  22. data/app/controllers/upright/public/base_controller.rb +16 -0
  23. data/app/controllers/upright/public/incidents_controller.rb +6 -0
  24. data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
  25. data/app/controllers/upright/public/services_controller.rb +6 -0
  26. data/app/controllers/upright/sessions_controller.rb +37 -1
  27. data/app/controllers/upright/traces_controller.rb +88 -0
  28. data/app/helpers/upright/application_helper.rb +22 -2
  29. data/app/helpers/upright/artifacts_helper.rb +20 -0
  30. data/app/helpers/upright/probe_results_helper.rb +4 -10
  31. data/app/helpers/upright/public/services_helper.rb +70 -0
  32. data/app/javascript/upright/application.js +3 -0
  33. data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
  34. data/app/javascript/upright/controllers/template_controller.js +11 -0
  35. data/app/javascript/upright/public.js +5 -0
  36. data/app/jobs/upright/health_metrics_job.rb +27 -0
  37. data/app/jobs/upright/incident_reporter_job.rb +7 -0
  38. data/app/jobs/upright/maintenance_advance_job.rb +8 -0
  39. data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
  40. data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
  41. data/app/models/concerns/.keep +0 -0
  42. data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
  43. data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
  44. data/app/models/concerns/upright/playwright/form_authentication.rb +3 -5
  45. data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
  46. data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
  47. data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
  48. data/app/models/concerns/upright/probe_result/stale_cleanup.rb +47 -0
  49. data/app/models/concerns/upright/probeable.rb +16 -2
  50. data/app/models/concerns/upright/services/live_status.rb +69 -0
  51. data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
  52. data/app/models/upright/artifact.rb +2 -1
  53. data/app/models/upright/http/request.rb +13 -4
  54. data/app/models/upright/incident/automatic_report.rb +3 -0
  55. data/app/models/upright/incident.rb +87 -0
  56. data/app/models/upright/incident_affected_service.rb +9 -0
  57. data/app/models/upright/incident_update.rb +7 -0
  58. data/app/models/upright/maintenance.rb +36 -0
  59. data/app/models/upright/persistent_record.rb +12 -0
  60. data/app/models/upright/playwright/authenticator/base.rb +50 -70
  61. data/app/models/upright/probe_result.rb +6 -1
  62. data/app/models/upright/probes/http_probe.rb +30 -1
  63. data/app/models/upright/probes/playwright/base.rb +2 -0
  64. data/app/models/upright/probes/status/probe.rb +8 -0
  65. data/app/models/upright/probes/status.rb +5 -13
  66. data/app/models/upright/probes/uptime.rb +5 -12
  67. data/app/models/upright/rollups/daily_uptime.rb +28 -0
  68. data/app/models/upright/rollups/probe_rollup.rb +64 -0
  69. data/app/models/upright/rollups/probe_uptime.rb +9 -0
  70. data/app/models/upright/rollups/site_uptime.rb +75 -0
  71. data/app/models/upright/service/daily_status.rb +39 -0
  72. data/app/models/upright/service/incident_history.rb +24 -0
  73. data/app/models/upright/service/outage.rb +15 -0
  74. data/app/models/upright/service/status_page.rb +35 -0
  75. data/app/models/upright/service.rb +101 -0
  76. data/app/models/upright/status.rb +21 -0
  77. data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
  78. data/app/models/upright/traceroute/result.rb +8 -1
  79. data/app/views/layouts/upright/_header.html.erb +12 -2
  80. data/app/views/layouts/upright/application.html.erb +4 -1
  81. data/app/views/layouts/upright/public.html.erb +18 -0
  82. data/app/views/layouts/upright/public.rss.builder +1 -0
  83. data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
  84. data/app/views/upright/artifacts/show.html.erb +8 -0
  85. data/app/views/upright/dashboards/_uptime_probe_row.html.erb +5 -3
  86. data/app/views/upright/dashboards/probe_statuses/show.html.erb +1 -1
  87. data/app/views/upright/dashboards/uptimes/show.html.erb +1 -1
  88. data/app/views/upright/incidents/_form.html.erb +93 -0
  89. data/app/views/upright/incidents/_row.html.erb +15 -0
  90. data/app/views/upright/incidents/edit.html.erb +64 -0
  91. data/app/views/upright/incidents/index.html.erb +53 -0
  92. data/app/views/upright/incidents/new.html.erb +6 -0
  93. data/app/views/upright/probe_results/index.html.erb +7 -4
  94. data/app/views/upright/public/_branding.html.erb +3 -0
  95. data/app/views/upright/public/_footer.html.erb +3 -0
  96. data/app/views/upright/public/incidents/show.html.erb +29 -0
  97. data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
  98. data/app/views/upright/public/service_incidents/index.html.erb +50 -0
  99. data/app/views/upright/public/services/_active_events.html.erb +28 -0
  100. data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
  101. data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
  102. data/app/views/upright/public/services/_service.html.erb +34 -0
  103. data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
  104. data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
  105. data/app/views/upright/public/services/index.html.erb +13 -0
  106. data/app/views/upright/public/services/index.rss.builder +29 -0
  107. data/app/views/upright/sessions/new.html.erb +14 -1
  108. data/config/ci.rb +2 -0
  109. data/config/importmap.rb +25 -14
  110. data/config/initializers/date_formats.rb +5 -0
  111. data/config/initializers/mime_types.rb +1 -0
  112. data/config/routes.rb +26 -2
  113. data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
  114. data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
  115. data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
  116. data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
  117. data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
  118. data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
  119. data/lib/generators/upright/install/database_config.rb +46 -0
  120. data/lib/generators/upright/install/install_generator.rb +52 -3
  121. data/lib/generators/upright/install/templates/Dockerfile +80 -0
  122. data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
  123. data/lib/generators/upright/install/templates/deploy.yml +13 -13
  124. data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
  125. data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
  126. data/lib/generators/upright/install/templates/http_probes.yml +1 -0
  127. data/lib/generators/upright/install/templates/omniauth.rb +7 -3
  128. data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
  129. data/lib/generators/upright/install/templates/recurring.yml +45 -0
  130. data/lib/generators/upright/install/templates/sites.yml +4 -0
  131. data/lib/generators/upright/install/templates/smtp_probes.yml +3 -0
  132. data/lib/generators/upright/install/templates/traceroute_probes.yml +3 -0
  133. data/lib/generators/upright/install/templates/upright.rb +24 -2
  134. data/lib/generators/upright/install/templates/upright.rules.yml +14 -7
  135. data/lib/upright/configuration.rb +127 -6
  136. data/lib/upright/engine.rb +32 -2
  137. data/lib/upright/metrics.rb +27 -2
  138. data/lib/upright/probe_type_registry.rb +33 -0
  139. data/lib/upright/site.rb +31 -1
  140. data/lib/upright/version.rb +3 -1
  141. data/lib/upright.rb +34 -2
  142. metadata +110 -7
  143. data/config/credentials/development.key +0 -1
  144. data/config/credentials/test.key +0 -1
  145. 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 authenticated_context(browser, context_options = {})
16
+ def ensure_authenticated
16
17
  if authentication_service
17
- authenticator_for(authentication_service).new(browser, context_options).authenticated_context
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
- if ENV["LOCAL_PLAYWRIGHT"]
20
- ::Playwright.create(playwright_cli_executable_path: "./node_modules/.bin/playwright") do |playwright|
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
- # Rescue each step independently so a failed close doesn't prevent video capture
37
- page&.close rescue Rails.error.report($!)
38
- context&.close rescue Rails.error.report($!)
39
- run_callbacks :page_close
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
- authenticated_context(browser, options) || browser.new_context(userAgent: user_agent, **options)
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 :video_path, :video_object
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
- self.video_object = page.video if record_video?
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
- if video_object
41
- self.video_path = video_dir.join("#{SecureRandom.hex}.webm").to_s
42
- video_object.save_as(video_path)
43
- end
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
- if video_path
48
- File.open(video_path, "rb") do |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
- if logger.respond_to?(:struct) && probe_result.artifacts.any?
53
- logger.struct probe_artifact_url: Rails.application.routes.url_helpers.rails_blob_url(probe_result.artifacts.first, expires_in: 24.hours)
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(video_path)
57
- self.video_path = nil
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
- TYPES = %w[ http playwright smtp traceroute ]
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
- nil
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
@@ -6,7 +6,8 @@ class Upright::Artifact
6
6
  "html" => "download",
7
7
  "xml" => "download",
8
8
  "txt" => "download",
9
- "bin" => "download"
9
+ "bin" => "download",
10
+ "zip" => "download"
10
11
  }.freeze
11
12
 
12
13
  attr_reader :filename, :content
@@ -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: true
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
@@ -0,0 +1,3 @@
1
+ class Upright::Incident::AutomaticReport < Upright::PersistentRecord
2
+ belongs_to :incident, class_name: "Upright::Incident", inverse_of: :automatic_report, optional: true
3
+ end