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