upright 0.1.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 (121) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +10 -0
  3. data/README.md +455 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/stylesheets/upright/_global.css +104 -0
  6. data/app/assets/stylesheets/upright/artifact.css +148 -0
  7. data/app/assets/stylesheets/upright/base.css +68 -0
  8. data/app/assets/stylesheets/upright/buttons.css +21 -0
  9. data/app/assets/stylesheets/upright/dashboard.css +287 -0
  10. data/app/assets/stylesheets/upright/forms.css +104 -0
  11. data/app/assets/stylesheets/upright/header.css +124 -0
  12. data/app/assets/stylesheets/upright/layout.css +100 -0
  13. data/app/assets/stylesheets/upright/map.css +25 -0
  14. data/app/assets/stylesheets/upright/pagination.css +45 -0
  15. data/app/assets/stylesheets/upright/probes.css +72 -0
  16. data/app/assets/stylesheets/upright/reset.css +26 -0
  17. data/app/assets/stylesheets/upright/tables.css +63 -0
  18. data/app/assets/stylesheets/upright/typography.css +27 -0
  19. data/app/assets/stylesheets/upright/uptime-bars.css +154 -0
  20. data/app/controllers/concerns/upright/authentication.rb +21 -0
  21. data/app/controllers/concerns/upright/subdomain_scoping.rb +18 -0
  22. data/app/controllers/upright/alertmanager_proxy_controller.rb +21 -0
  23. data/app/controllers/upright/application_controller.rb +12 -0
  24. data/app/controllers/upright/artifacts_controller.rb +5 -0
  25. data/app/controllers/upright/dashboards/uptimes_controller.rb +6 -0
  26. data/app/controllers/upright/jobs_controller.rb +4 -0
  27. data/app/controllers/upright/probe_results_controller.rb +17 -0
  28. data/app/controllers/upright/prometheus_proxy_controller.rb +62 -0
  29. data/app/controllers/upright/sessions_controller.rb +29 -0
  30. data/app/controllers/upright/sites_controller.rb +5 -0
  31. data/app/helpers/upright/application_helper.rb +11 -0
  32. data/app/helpers/upright/dashboards_helper.rb +31 -0
  33. data/app/helpers/upright/probe_results_helper.rb +49 -0
  34. data/app/javascript/upright/application.js +2 -0
  35. data/app/javascript/upright/controllers/application.js +5 -0
  36. data/app/javascript/upright/controllers/form_controller.js +7 -0
  37. data/app/javascript/upright/controllers/index.js +4 -0
  38. data/app/javascript/upright/controllers/popover_controller.js +15 -0
  39. data/app/javascript/upright/controllers/probe_results_chart_controller.js +79 -0
  40. data/app/javascript/upright/controllers/results_table_controller.js +16 -0
  41. data/app/javascript/upright/controllers/sites_map_controller.js +33 -0
  42. data/app/jobs/upright/application_job.rb +2 -0
  43. data/app/jobs/upright/probe_check_job.rb +42 -0
  44. data/app/models/concerns/upright/exception_recording.rb +38 -0
  45. data/app/models/concerns/upright/playwright/form_authentication.rb +27 -0
  46. data/app/models/concerns/upright/playwright/helpers.rb +7 -0
  47. data/app/models/concerns/upright/playwright/lifecycle.rb +44 -0
  48. data/app/models/concerns/upright/playwright/logging.rb +87 -0
  49. data/app/models/concerns/upright/playwright/otel_tracing.rb +137 -0
  50. data/app/models/concerns/upright/playwright/video_recording.rb +60 -0
  51. data/app/models/concerns/upright/probe_yaml_source.rb +10 -0
  52. data/app/models/concerns/upright/probeable.rb +125 -0
  53. data/app/models/concerns/upright/staggerable.rb +22 -0
  54. data/app/models/concerns/upright/traceroute/otel_tracing.rb +108 -0
  55. data/app/models/upright/application_record.rb +3 -0
  56. data/app/models/upright/artifact.rb +61 -0
  57. data/app/models/upright/current.rb +9 -0
  58. data/app/models/upright/http/request.rb +59 -0
  59. data/app/models/upright/http/response.rb +55 -0
  60. data/app/models/upright/playwright/authenticator/base.rb +128 -0
  61. data/app/models/upright/playwright/storage_state.rb +31 -0
  62. data/app/models/upright/probe_result.rb +31 -0
  63. data/app/models/upright/probes/http_probe.rb +102 -0
  64. data/app/models/upright/probes/playwright/base.rb +48 -0
  65. data/app/models/upright/probes/smtp_probe.rb +48 -0
  66. data/app/models/upright/probes/traceroute_probe.rb +32 -0
  67. data/app/models/upright/probes/uptime/summary.rb +36 -0
  68. data/app/models/upright/probes/uptime.rb +36 -0
  69. data/app/models/upright/traceroute/hop.rb +49 -0
  70. data/app/models/upright/traceroute/ip_metadata_lookup.rb +107 -0
  71. data/app/models/upright/traceroute/mtr_parser.rb +47 -0
  72. data/app/models/upright/traceroute/result.rb +57 -0
  73. data/app/models/upright/user.rb +14 -0
  74. data/app/views/layouts/upright/_header.html.erb +23 -0
  75. data/app/views/layouts/upright/application.html.erb +25 -0
  76. data/app/views/upright/active_storage/attachments/_attachment.html.erb +21 -0
  77. data/app/views/upright/alertmanager_proxy/show.html.erb +1 -0
  78. data/app/views/upright/artifacts/show.html.erb +9 -0
  79. data/app/views/upright/dashboards/_uptime_bars.html.erb +17 -0
  80. data/app/views/upright/dashboards/_uptime_probe_row.html.erb +22 -0
  81. data/app/views/upright/dashboards/uptimes/show.html.erb +17 -0
  82. data/app/views/upright/jobs/show.html.erb +1 -0
  83. data/app/views/upright/probe_results/_pagination.html.erb +19 -0
  84. data/app/views/upright/probe_results/index.html.erb +72 -0
  85. data/app/views/upright/prometheus_proxy/show.html.erb +1 -0
  86. data/app/views/upright/sessions/new.html.erb +6 -0
  87. data/app/views/upright/sites/index.html.erb +22 -0
  88. data/config/brakeman.ignore +39 -0
  89. data/config/ci.rb +7 -0
  90. data/config/importmap.rb +18 -0
  91. data/config/routes.rb +41 -0
  92. data/db/migrate/20250114000001_create_upright_probe_results.rb +19 -0
  93. data/lib/generators/upright/install/install_generator.rb +83 -0
  94. data/lib/generators/upright/install/templates/alertmanager.yml +14 -0
  95. data/lib/generators/upright/install/templates/deploy.yml +118 -0
  96. data/lib/generators/upright/install/templates/development_alertmanager.yml +11 -0
  97. data/lib/generators/upright/install/templates/development_prometheus.yml +12 -0
  98. data/lib/generators/upright/install/templates/docker-compose.yml +38 -0
  99. data/lib/generators/upright/install/templates/http_probes.yml +14 -0
  100. data/lib/generators/upright/install/templates/omniauth.rb +8 -0
  101. data/lib/generators/upright/install/templates/otel_collector.yml +24 -0
  102. data/lib/generators/upright/install/templates/prometheus.yml +10 -0
  103. data/lib/generators/upright/install/templates/puma.rb +40 -0
  104. data/lib/generators/upright/install/templates/sites.yml +26 -0
  105. data/lib/generators/upright/install/templates/smtp_probes.yml +9 -0
  106. data/lib/generators/upright/install/templates/upright.rb +21 -0
  107. data/lib/generators/upright/install/templates/upright.rules.yml +256 -0
  108. data/lib/generators/upright/playwright_probe/playwright_probe_generator.rb +30 -0
  109. data/lib/generators/upright/playwright_probe/templates/authenticator.rb.tt +14 -0
  110. data/lib/generators/upright/playwright_probe/templates/probe.rb.tt +14 -0
  111. data/lib/omniauth/strategies/static_credentials.rb +57 -0
  112. data/lib/tasks/upright_tasks.rake +4 -0
  113. data/lib/upright/configuration.rb +106 -0
  114. data/lib/upright/engine.rb +157 -0
  115. data/lib/upright/metrics.rb +62 -0
  116. data/lib/upright/playwright/collect_performance_metrics.js +36 -0
  117. data/lib/upright/site.rb +49 -0
  118. data/lib/upright/tracing.rb +49 -0
  119. data/lib/upright/version.rb +3 -0
  120. data/lib/upright.rb +68 -0
  121. metadata +513 -0
@@ -0,0 +1,5 @@
1
+ class Upright::ArtifactsController < Upright::ApplicationController
2
+ def show
3
+ @artifact = ActiveStorage::Attachment.find(params[:id])
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class Upright::Dashboards::UptimesController < Upright::ApplicationController
2
+ def show
3
+ @probe_type = params.fetch(:probe_type, :http)
4
+ @probes = Upright::Probes::Uptime.for_type(@probe_type)
5
+ end
6
+ end
@@ -0,0 +1,4 @@
1
+ class Upright::JobsController < Upright::ApplicationController
2
+ def show
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ class Upright::ProbeResultsController < Upright::ApplicationController
2
+ def index
3
+ set_page_and_extract_portion_from probe_results, ordered_by: { id: :desc }
4
+
5
+ @probe_names = Upright::ProbeResult.by_type(params[:probe_type]).distinct.pluck(:probe_name).sort
6
+ @chart_data = @page.records.map(&:to_chart)
7
+ end
8
+
9
+ private
10
+ def probe_results
11
+ Upright::ProbeResult
12
+ .by_type(params[:probe_type])
13
+ .by_status(params[:status])
14
+ .by_name(params[:probe_name])
15
+ .with_attached_artifacts
16
+ end
17
+ end
@@ -0,0 +1,62 @@
1
+ class Upright::PrometheusProxyController < Upright::ApplicationController
2
+ skip_forgery_protection
3
+
4
+ skip_before_action :authenticate_user, only: :otlp
5
+ before_action :authenticate_otlp_token, only: :otlp
6
+
7
+ UNSUPPORTED_PATHS = %w[/api/v1/notifications]
8
+
9
+ def show
10
+ end
11
+
12
+ def proxy
13
+ path = request.fullpath.sub(%r{^/prometheus}, "")
14
+
15
+ if path.start_with?(*UNSUPPORTED_PATHS)
16
+ head :not_found
17
+ else
18
+ proxy_to_prometheus(path)
19
+ end
20
+ end
21
+
22
+ def otlp
23
+ response = prometheus_connection.post("/api/v1/otlp/v1/metrics") do |req|
24
+ req.headers["Content-Type"] = request.content_type
25
+ req.body = request.body.read
26
+ end
27
+
28
+ render body: response.body, status: response.status, content_type: response.headers["content-type"]
29
+ end
30
+
31
+ private
32
+ def proxy_to_prometheus(path, method: request.method, body: nil)
33
+ response = prometheus_connection.run_request(
34
+ method.downcase.to_sym,
35
+ path,
36
+ body,
37
+ { "Content-Type" => request.content_type }
38
+ )
39
+
40
+ if response.status.in?([ 301, 302 ]) && response.headers["location"]
41
+ redirect_to "/prometheus#{response.headers['location']}", status: response.status, allow_other_host: true
42
+ else
43
+ render body: response.body, status: response.status, content_type: response.headers["content-type"]
44
+ end
45
+ end
46
+
47
+ def prometheus_connection
48
+ @prometheus_connection ||= Faraday.new(url: prometheus_url) do |f|
49
+ f.options.timeout = 30
50
+ end
51
+ end
52
+
53
+ def prometheus_url
54
+ ENV.fetch("PROMETHEUS_URL", "http://localhost:9090")
55
+ end
56
+
57
+ def authenticate_otlp_token
58
+ authenticate_or_request_with_http_token do |token|
59
+ ActiveSupport::SecurityUtils.secure_compare(token, ENV.fetch("PROMETHEUS_OTLP_TOKEN", ""))
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,29 @@
1
+ class Upright::SessionsController < Upright::ApplicationController
2
+ skip_before_action :authenticate_user, only: [ :new, :create ]
3
+ skip_forgery_protection only: :create
4
+
5
+ before_action :ensure_not_signed_in, only: [ :new, :create ]
6
+
7
+ def new
8
+ end
9
+
10
+ def create
11
+ user = Upright::User.from_omniauth(request.env["omniauth.auth"])
12
+ session[:user_info] = { email: user.email, name: user.name }
13
+ redirect_to upright.root_path
14
+ end
15
+
16
+ def destroy
17
+ reset_session
18
+ redirect_to upright.root_path(subdomain: Upright.configuration.global_subdomain), allow_other_host: true
19
+ end
20
+
21
+ private
22
+ def ensure_not_signed_in
23
+ redirect_to upright.site_root_path if session[:user_info].present?
24
+ end
25
+
26
+ def upright
27
+ Upright::Engine.routes.url_helpers
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ class Upright::SitesController < Upright::ApplicationController
2
+ def index
3
+ @sites = Upright.sites
4
+ end
5
+ end
@@ -0,0 +1,11 @@
1
+ module Upright::ApplicationHelper
2
+ def current_or_default_site
3
+ Upright::Current.site || Upright.sites.first
4
+ end
5
+
6
+ def upright_stylesheet_link_tag(**options)
7
+ Upright::Engine.root.join("app/assets/stylesheets/upright").glob("*.css")
8
+ .map { |f| "upright/#{f.basename('.css')}" }.sort
9
+ .then { |stylesheets| stylesheet_link_tag(*stylesheets, **options) }
10
+ end
11
+ end
@@ -0,0 +1,31 @@
1
+ module Upright::DashboardsHelper
2
+ def date_range
3
+ (29.days.ago.to_date..Date.current).to_a
4
+ end
5
+
6
+ def uptime_label(percentage)
7
+ case percentage
8
+ when 100 then "excellent"
9
+ when 99..100 then "good"
10
+ when 95..99 then "warning"
11
+ when 0.01..95 then "critical"
12
+ else "down"
13
+ end
14
+ end
15
+
16
+ def uptime_bar_tooltip(date, uptime_percent, downtime_minutes)
17
+ tooltip = "#{date.to_fs(:short)}: #{number_with_precision(uptime_percent, precision: 1)}% uptime"
18
+ tooltip += " (#{format_downtime(downtime_minutes)} down)" if downtime_minutes > 0
19
+ tooltip
20
+ end
21
+
22
+ def format_downtime(minutes)
23
+ if minutes < 60
24
+ "#{minutes}m"
25
+ else
26
+ hours = minutes / 60
27
+ mins = minutes % 60
28
+ mins.zero? ? "#{hours}h" : "#{hours}h #{mins}m"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ module Upright::ProbeResultsHelper
2
+ PROBE_TYPE_ICONS = {
3
+ http: "🌐",
4
+ playwright: "🎭",
5
+ ping: "📶",
6
+ smtp: "✉️",
7
+ traceroute: "🛤️"
8
+ }
9
+
10
+ def probe_type_icon(probe_type)
11
+ icon = PROBE_TYPE_ICONS.fetch(probe_type.to_s.downcase.to_sym)
12
+ content_tag(:span, icon, title: probe_type.titleize)
13
+ end
14
+
15
+ def type_filter_link(label, probe_type = nil)
16
+ display_label = probe_type ? safe_join([ probe_type_icon(probe_type), " ", label ]) : label
17
+
18
+ link_to display_label,
19
+ site_root_path(probe_type: probe_type.presence, status: params[:status].presence),
20
+ class: class_names(active: params[:probe_type].presence == probe_type)
21
+ end
22
+
23
+ def artifact_icon(artifact)
24
+ case artifact.filename
25
+ when Upright::ExceptionRecording::EXCEPTION_FILENAME then "💥"
26
+ when /\.webm$/ then "🎬"
27
+ when /^request\.log$/ then "📤"
28
+ when /^response\./ then "📥"
29
+ when /^smtp\.log$/ then "📧"
30
+ else "📎"
31
+ end
32
+ end
33
+
34
+ def results_summary(page)
35
+ total = page.recordset.records_count
36
+ current_count = page.records.size
37
+
38
+ parts = if page.recordset.page_count > 1
39
+ [ "Showing #{current_count} of #{total} results" ]
40
+ else
41
+ [ "Showing #{total} results" ]
42
+ end
43
+
44
+ parts << "for #{params[:probe_type].titleize} probes" if params[:probe_type].present?
45
+ parts << "named #{params[:probe_name]}" if params[:probe_name].present?
46
+ parts << "with status #{params[:status]}" if params[:status].present?
47
+ parts.join(" ")
48
+ end
49
+ end
@@ -0,0 +1,2 @@
1
+ import "@hotwired/turbo-rails"
2
+ import "upright/controllers"
@@ -0,0 +1,5 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+
3
+ const application = Application.start()
4
+
5
+ export { application }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ submit() {
5
+ this.element.requestSubmit()
6
+ }
7
+ }
@@ -0,0 +1,4 @@
1
+ import { application } from "upright/controllers/application"
2
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
3
+
4
+ eagerLoadControllersFrom("upright/controllers", application)
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["frame"]
5
+
6
+ loadFrame() {
7
+ if (this.element.open && this.hasFrameTarget) {
8
+ this.frameTarget.loading = "eager"
9
+ }
10
+ }
11
+
12
+ close() {
13
+ this.element.removeAttribute("open")
14
+ }
15
+ }
@@ -0,0 +1,79 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Chart } from "frappe-charts"
3
+
4
+ export default class extends Controller {
5
+ static values = { results: Array }
6
+
7
+ connect() {
8
+ this.results = this.resultsValue.slice().reverse()
9
+
10
+ if (this.results.length > 0) {
11
+ this.renderChart()
12
+ }
13
+ }
14
+
15
+ renderChart() {
16
+ new Chart(this.element, {
17
+ data: this.chartData,
18
+ type: "line",
19
+ height: 200,
20
+ colors: this.colors,
21
+ lineOptions: { hideDots: 0, dotSize: 3, regionFill: 0 },
22
+ axisOptions: { xAxisMode: "tick", xIsSeries: true },
23
+ tooltipOptions: {
24
+ formatTooltipX: d => this.tooltipLabels[this.labels.indexOf(d)] || d,
25
+ formatTooltipY: d => d ? `${d.toFixed(3)}s` : null
26
+ }
27
+ })
28
+ }
29
+
30
+ get chartData() {
31
+ return { labels: this.labels, datasets: this.datasets }
32
+ }
33
+
34
+ get labels() {
35
+ return this._labels ||= this.results.map(r => this.formatTime(r.created_at))
36
+ }
37
+
38
+ get tooltipLabels() {
39
+ return this._tooltipLabels ||= this.results.map(r => `${r.probe_name} @ ${this.formatTime(r.created_at)}`)
40
+ }
41
+
42
+ get datasets() {
43
+ const datasets = []
44
+ if (this.hasOkResults) datasets.push({ name: "Ok", values: this.okData, chartType: "line" })
45
+ if (this.hasFailResults) datasets.push({ name: "Fail", values: this.failData, chartType: "line" })
46
+ return datasets
47
+ }
48
+
49
+ get colors() {
50
+ const colors = []
51
+ if (this.hasOkResults) colors.push(this.isDarkMode ? "#4ade80" : "#22c55e")
52
+ if (this.hasFailResults) colors.push(this.isDarkMode ? "#f87171" : "#ef4444")
53
+ return colors
54
+ }
55
+
56
+ get okData() {
57
+ return this._okData ||= this.results.map(r => r.status === "ok" ? r.duration : null)
58
+ }
59
+
60
+ get failData() {
61
+ return this._failData ||= this.results.map(r => r.status !== "ok" ? r.duration : null)
62
+ }
63
+
64
+ get hasOkResults() {
65
+ return this.okData.some(d => d !== null)
66
+ }
67
+
68
+ get hasFailResults() {
69
+ return this.failData.some(d => d !== null)
70
+ }
71
+
72
+ get isDarkMode() {
73
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
74
+ }
75
+
76
+ formatTime(timestamp) {
77
+ return new Date(timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
78
+ }
79
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["popover"]
5
+
6
+ openArtifact(event) {
7
+ if (event.target.closest("details, a, button")) return
8
+
9
+ const row = event.target.closest("tr")
10
+ const popover = this.popoverTargets.find(p => row.contains(p))
11
+
12
+ if (popover) {
13
+ popover.open = true
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import * as L from "leaflet"
3
+
4
+ export default class extends Controller {
5
+ static values = { sites: Array }
6
+
7
+ connect() {
8
+ const map = L.map(this.element, { scrollWheelZoom: false }).setView([30, 0], 2)
9
+
10
+ L.tileLayer(this.tileUrl, { attribution: this.attribution }).addTo(map)
11
+
12
+ for (const site of this.sitesValue) {
13
+ if (!site.lat || !site.lon) continue
14
+
15
+ L.marker([site.lat, site.lon])
16
+ .addTo(map)
17
+ .bindPopup(`<strong>${site.hostname}</strong><br>${site.city}`)
18
+ .on("mouseover", function() { this.openPopup() })
19
+ .on("mouseout", function() { this.closePopup() })
20
+ .on("click", () => window.location.href = site.url)
21
+ }
22
+ }
23
+
24
+ get tileUrl() {
25
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
26
+ ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
27
+ : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
28
+ }
29
+
30
+ get attribution() {
31
+ return '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
32
+ }
33
+ }
@@ -0,0 +1,2 @@
1
+ class Upright::ApplicationJob < ActiveJob::Base
2
+ end
@@ -0,0 +1,42 @@
1
+ class Upright::ProbeCheckJob < Upright::ApplicationJob
2
+ MAX_ATTEMPTS = 2
3
+ RETRY_DELAY = 5.seconds
4
+
5
+ before_enqueue { self.scheduled_at = stagger_delay.from_now }
6
+ before_perform :discard_if_stale
7
+ around_perform { |job, block| Timeout.timeout(3.minutes, &block) }
8
+
9
+ def perform(klass, name = nil)
10
+ result = resolve_probe(klass, name).check_and_record
11
+
12
+ if !result.ok? && executions < MAX_ATTEMPTS
13
+ retry_job(wait: RETRY_DELAY)
14
+ end
15
+ end
16
+
17
+ private
18
+ def stagger_delay
19
+ arguments[0].constantize.stagger_delay
20
+ end
21
+
22
+ def discard_if_stale
23
+ if scheduled_at && scheduled_at < 5.minutes.ago
24
+ logger.info "Discarding stale probe job scheduled at #{scheduled_at}"
25
+ throw :abort
26
+ end
27
+ end
28
+
29
+ def resolve_probe(klass, name)
30
+ klass = klass.safe_constantize
31
+
32
+ instance = if klass < FrozenRecord::Base
33
+ klass.find_by(name: name)
34
+ else
35
+ klass.new
36
+ end
37
+
38
+ instance.logger = logger
39
+
40
+ instance
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ module Upright::ExceptionRecording
2
+ extend ActiveSupport::Concern
3
+
4
+ EXCEPTION_FILENAME = "exception.txt"
5
+
6
+ included do
7
+ attr_accessor :error
8
+
9
+ after_create :attach_exception
10
+ end
11
+
12
+ def attach_exception
13
+ if error
14
+ artifacts.attach(
15
+ io: StringIO.new(format_exception(error)),
16
+ filename: EXCEPTION_FILENAME,
17
+ content_type: "text/plain"
18
+ )
19
+ end
20
+ end
21
+
22
+ def exception_artifact
23
+ artifacts.find { |a| a.filename.to_s == EXCEPTION_FILENAME }
24
+ end
25
+
26
+ def exception_report
27
+ exception_artifact&.download
28
+ end
29
+
30
+ private
31
+ def format_exception(exception)
32
+ lines = [ "#{exception.class}: #{exception.message}" ]
33
+ if exception.backtrace
34
+ lines.concat(exception.backtrace.first(20).map { |line| " #{line}" })
35
+ end
36
+ lines.join("\n")
37
+ end
38
+ end
@@ -0,0 +1,27 @@
1
+ module Upright::Playwright::FormAuthentication
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ class_attribute :authentication_service
6
+ end
7
+
8
+ class_methods do
9
+ def authenticate_with_form(service)
10
+ self.authentication_service = service
11
+ end
12
+ end
13
+
14
+ private
15
+ def authenticated_context(browser, context_options = {})
16
+ if authentication_service
17
+ authenticator_for(authentication_service).new(browser, context_options).authenticated_context
18
+ end
19
+ end
20
+
21
+ def authenticator_for(service)
22
+ # First try the host app's authenticator, then fall back to engine's
23
+ "::Playwright::Authenticator::#{service.to_s.camelize}".constantize
24
+ rescue NameError
25
+ "Upright::Playwright::Authenticator::#{service.to_s.camelize}".constantize
26
+ end
27
+ end
@@ -0,0 +1,7 @@
1
+ module Upright::Playwright::Helpers
2
+ extend ActiveSupport::Concern
3
+
4
+ def wait_for_network_idle(target_page = nil)
5
+ (target_page || page).wait_for_load_state(state: "networkidle")
6
+ end
7
+ end
@@ -0,0 +1,44 @@
1
+ module Upright::Playwright::Lifecycle
2
+ extend ActiveSupport::Concern
3
+
4
+ DEFAULT_USER_AGENT = "Upright/1.0 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
5
+
6
+ included do
7
+ attr_accessor :context, :page
8
+
9
+ define_callbacks :page_ready
10
+ define_callbacks :page_close
11
+ end
12
+
13
+ def user_agent
14
+ Upright.configuration.user_agent.presence || DEFAULT_USER_AGENT
15
+ end
16
+
17
+ private
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)
27
+ end
28
+ end
29
+
30
+ def with_context(browser, **options, &block)
31
+ self.context = create_context(browser, **options)
32
+ self.page = context.new_page
33
+ run_callbacks :page_ready
34
+ yield
35
+ ensure
36
+ page&.close
37
+ context&.close
38
+ run_callbacks :page_close
39
+ end
40
+
41
+ def create_context(browser, **options)
42
+ authenticated_context(browser, options) || browser.new_context(userAgent: user_agent, **options)
43
+ end
44
+ end
@@ -0,0 +1,87 @@
1
+ module Upright::Playwright::Logging
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ attr_reader :log_lines
6
+
7
+ set_callback :perform_check, :before, :start_resource_logging
8
+ set_callback :perform_check, :after, :log_performance_metrics
9
+ end
10
+
11
+ def start_resource_logging
12
+ @log_lines = []
13
+
14
+ # Use structured logging if available, otherwise just log normally
15
+ if defined?(RailsStructuredLogging::Recorder)
16
+ RailsStructuredLogging::Recorder.instance.messages.tap do |messages|
17
+ page.on("response", ->(response) {
18
+ next if skip_logging?(response)
19
+ RailsStructuredLogging::Recorder.instance.sharing(messages)
20
+ log_response(response)
21
+ })
22
+ end
23
+ else
24
+ page.on("response", ->(response) {
25
+ next if skip_logging?(response)
26
+ log_response(response)
27
+ })
28
+ end
29
+ end
30
+
31
+ def log_response(response)
32
+ headers = response.headers.slice("x-request-id", "x-runtime", "x-ratelimit-limit", "x-ratelimit-remaining").compact.map { |k, v| "#{k}=#{v}" }.join(" ")
33
+ "#{response.status} #{response.request.resource_type.upcase} #{response.url} #{headers}".strip.tap do |line|
34
+ log_lines << line
35
+ Rails.logger.info line
36
+ end
37
+ end
38
+
39
+ def log_performance_metrics
40
+ current_site = Upright.current_site
41
+
42
+ log_metrics url: page.url,
43
+ total_resource_bytes: fetch_total_resource_bytes,
44
+ total_load_ms: fetch_total_load_ms,
45
+ site_code: current_site.code,
46
+ site_city: current_site.city,
47
+ site_country: current_site.country,
48
+ site_geohash: current_site.geohash
49
+ end
50
+
51
+ def log_metrics(**metrics)
52
+ if logger.respond_to?(:struct)
53
+ logger.struct probe: metrics
54
+ else
55
+ logger.info metrics.to_json
56
+ end
57
+ end
58
+
59
+ def fetch_total_resource_bytes
60
+ page.evaluate <<~JS
61
+ performance.getEntriesByType("resource").reduce((size, item) => {
62
+ size += item.decodedBodySize;
63
+ return size;
64
+ }, 0);
65
+ JS
66
+ end
67
+
68
+ def fetch_total_load_ms
69
+ page.evaluate <<~JS
70
+ const perfEntries = performance.getEntriesByType("navigation")
71
+ perfEntries[0].loadEventEnd - perfEntries[0].startTime;
72
+ JS
73
+ end
74
+
75
+ def attach_log(probe_result)
76
+ if log_lines&.any?
77
+ Upright::Artifact.new(name: "#{probe_name}.log", content: log_lines.join("\n")).attach_to(probe_result, timestamped: true)
78
+ end
79
+ end
80
+
81
+ private
82
+ SKIP_URL_PATTERNS = %w[ image asset avatar ]
83
+
84
+ def skip_logging?(response)
85
+ SKIP_URL_PATTERNS.any? { |skip_pattern| response.url.include?(skip_pattern) }
86
+ end
87
+ end