upright 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +66 -7
  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/controllers/concerns/.keep +0 -0
  13. data/app/controllers/concerns/upright/authentication.rb +1 -0
  14. data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
  15. data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
  16. data/app/controllers/upright/artifacts_controller.rb +1 -1
  17. data/app/controllers/upright/incidents/updates_controller.rb +13 -0
  18. data/app/controllers/upright/incidents_controller.rb +53 -0
  19. data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
  20. data/app/controllers/upright/public/base_controller.rb +16 -0
  21. data/app/controllers/upright/public/incidents_controller.rb +6 -0
  22. data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
  23. data/app/controllers/upright/public/services_controller.rb +6 -0
  24. data/app/controllers/upright/sessions_controller.rb +36 -1
  25. data/app/controllers/upright/traces_controller.rb +88 -0
  26. data/app/helpers/upright/application_helper.rb +22 -2
  27. data/app/helpers/upright/artifacts_helper.rb +20 -0
  28. data/app/helpers/upright/probe_results_helper.rb +1 -0
  29. data/app/helpers/upright/public/services_helper.rb +70 -0
  30. data/app/javascript/upright/application.js +3 -0
  31. data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
  32. data/app/javascript/upright/controllers/template_controller.js +11 -0
  33. data/app/javascript/upright/public.js +5 -0
  34. data/app/jobs/upright/health_metrics_job.rb +27 -0
  35. data/app/jobs/upright/incident_reporter_job.rb +7 -0
  36. data/app/jobs/upright/maintenance_advance_job.rb +8 -0
  37. data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
  38. data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
  39. data/app/models/concerns/.keep +0 -0
  40. data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
  41. data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
  42. data/app/models/concerns/upright/playwright/form_authentication.rb +3 -2
  43. data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
  44. data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
  45. data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
  46. data/app/models/concerns/upright/probe_result/stale_cleanup.rb +26 -2
  47. data/app/models/concerns/upright/probeable.rb +9 -1
  48. data/app/models/concerns/upright/services/live_status.rb +69 -0
  49. data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
  50. data/app/models/upright/artifact.rb +2 -1
  51. data/app/models/upright/http/request.rb +12 -3
  52. data/app/models/upright/incident/automatic_report.rb +3 -0
  53. data/app/models/upright/incident.rb +87 -0
  54. data/app/models/upright/incident_affected_service.rb +9 -0
  55. data/app/models/upright/incident_update.rb +7 -0
  56. data/app/models/upright/maintenance.rb +36 -0
  57. data/app/models/upright/persistent_record.rb +12 -0
  58. data/app/models/upright/playwright/authenticator/base.rb +50 -70
  59. data/app/models/upright/probes/http_probe.rb +30 -1
  60. data/app/models/upright/probes/playwright/base.rb +2 -0
  61. data/app/models/upright/probes/status/probe.rb +8 -0
  62. data/app/models/upright/probes/status.rb +3 -11
  63. data/app/models/upright/probes/uptime.rb +2 -9
  64. data/app/models/upright/rollups/daily_uptime.rb +28 -0
  65. data/app/models/upright/rollups/probe_rollup.rb +64 -0
  66. data/app/models/upright/rollups/probe_uptime.rb +9 -0
  67. data/app/models/upright/rollups/site_uptime.rb +75 -0
  68. data/app/models/upright/service/daily_status.rb +39 -0
  69. data/app/models/upright/service/incident_history.rb +24 -0
  70. data/app/models/upright/service/outage.rb +15 -0
  71. data/app/models/upright/service/status_page.rb +35 -0
  72. data/app/models/upright/service.rb +101 -0
  73. data/app/models/upright/status.rb +21 -0
  74. data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
  75. data/app/models/upright/traceroute/result.rb +8 -1
  76. data/app/views/layouts/upright/_header.html.erb +12 -2
  77. data/app/views/layouts/upright/application.html.erb +4 -1
  78. data/app/views/layouts/upright/public.html.erb +18 -0
  79. data/app/views/layouts/upright/public.rss.builder +1 -0
  80. data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
  81. data/app/views/upright/artifacts/show.html.erb +8 -0
  82. data/app/views/upright/incidents/_form.html.erb +93 -0
  83. data/app/views/upright/incidents/_row.html.erb +15 -0
  84. data/app/views/upright/incidents/edit.html.erb +64 -0
  85. data/app/views/upright/incidents/index.html.erb +53 -0
  86. data/app/views/upright/incidents/new.html.erb +6 -0
  87. data/app/views/upright/public/_branding.html.erb +3 -0
  88. data/app/views/upright/public/_footer.html.erb +3 -0
  89. data/app/views/upright/public/incidents/show.html.erb +29 -0
  90. data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
  91. data/app/views/upright/public/service_incidents/index.html.erb +50 -0
  92. data/app/views/upright/public/services/_active_events.html.erb +28 -0
  93. data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
  94. data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
  95. data/app/views/upright/public/services/_service.html.erb +34 -0
  96. data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
  97. data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
  98. data/app/views/upright/public/services/index.html.erb +13 -0
  99. data/app/views/upright/public/services/index.rss.builder +29 -0
  100. data/app/views/upright/sessions/new.html.erb +14 -1
  101. data/config/importmap.rb +25 -14
  102. data/config/initializers/date_formats.rb +5 -0
  103. data/config/initializers/mime_types.rb +1 -0
  104. data/config/routes.rb +26 -2
  105. data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
  106. data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
  107. data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
  108. data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
  109. data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
  110. data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
  111. data/lib/generators/upright/install/database_config.rb +46 -0
  112. data/lib/generators/upright/install/install_generator.rb +18 -10
  113. data/lib/generators/upright/install/templates/Dockerfile +80 -0
  114. data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
  115. data/lib/generators/upright/install/templates/deploy.yml +13 -13
  116. data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
  117. data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
  118. data/lib/generators/upright/install/templates/omniauth.rb +7 -3
  119. data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
  120. data/lib/generators/upright/install/templates/recurring.yml +20 -0
  121. data/lib/generators/upright/install/templates/sites.yml +4 -0
  122. data/lib/generators/upright/install/templates/upright.rb +21 -2
  123. data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
  124. data/lib/upright/configuration.rb +113 -6
  125. data/lib/upright/engine.rb +25 -2
  126. data/lib/upright/metrics.rb +25 -0
  127. data/lib/upright/site.rb +31 -1
  128. data/lib/upright/version.rb +3 -1
  129. data/lib/upright.rb +29 -2
  130. metadata +107 -7
  131. data/config/credentials/development.key +0 -1
  132. data/config/credentials/test.key +0 -1
  133. data/lib/tasks/upright_tasks.rake +0 -4
@@ -48,6 +48,7 @@ env:
48
48
  secret:
49
49
  - RAILS_MASTER_KEY
50
50
  - PROMETHEUS_OTLP_TOKEN
51
+ - ADMIN_PASSWORD
51
52
  tags:
52
53
  london:
53
54
  SITE_SUBDOMAIN: lon
@@ -62,7 +63,13 @@ volumes:
62
63
 
63
64
  accessories:
64
65
  otel_collector:
65
- image: otel/opentelemetry-collector-contrib:0.126.0
66
+ # Pinned by digest so deploys are immutable; the tag is documentation only.
67
+ image: otel/opentelemetry-collector-contrib:0.126.0@sha256:973747f78266a8ffec428417727e6b704559e9a30e4be8c1cca2899facd68689
68
+ # The collector runs unprivileged and only receives OTLP over the private
69
+ # Docker network — it does not mount the Docker socket or host log
70
+ # directories. To collect container logs, prefer a filelog receiver over
71
+ # a read-only mount of the specific log path rather than the Docker socket,
72
+ # which grants root-equivalent control of the host.
66
73
  env:
67
74
  secret:
68
75
  - OTEL_GATEWAY_USERNAME
@@ -71,17 +78,6 @@ accessories:
71
78
  - PROMETHEUS_OTLP_TOKEN
72
79
  files:
73
80
  - config/otel_collector.yml:/etc/otelcol-contrib/config.yaml
74
- volumes:
75
- - /var/run/docker.sock:/var/run/docker.sock
76
- - /var/lib/docker/containers:/var/lib/docker/containers
77
- options:
78
- user: 0
79
- roles:
80
- - jobs
81
-
82
- playwright:
83
- image: jacoblincool/playwright:chromium-server-1.55.0
84
- port: "127.0.0.1:53333:53333"
85
81
  roles:
86
82
  - jobs
87
83
 
@@ -89,12 +85,16 @@ accessories:
89
85
  image: prom/prometheus:v3.2.1
90
86
  hosts:
91
87
  - <%= app_domain %>
88
+ # No published port: Prometheus is only reachable over the private Docker
89
+ # network (the app proxies it). --web.enable-lifecycle is deliberately
90
+ # omitted — the unauthenticated reload/quit endpoints aren't needed since
91
+ # config changes redeploy the accessory; if you add it back, front it with
92
+ # --web.config.file authentication.
92
93
  cmd: >-
93
94
  --config.file=/etc/prometheus/prometheus.yml
94
95
  --storage.tsdb.path=/prometheus
95
96
  --storage.tsdb.retention.time=30d
96
97
  --web.enable-otlp-receiver
97
- --web.enable-lifecycle
98
98
  files:
99
99
  - config/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
100
100
  - config/prometheus/rules/upright.rules.yml:/etc/prometheus/rules/upright.rules.yml
@@ -9,4 +9,4 @@ scrape_configs:
9
9
  - job_name: "upright"
10
10
  scrape_interval: 5s
11
11
  static_configs:
12
- - targets: ["localhost:9394"]
12
+ - targets: ["host.docker.internal:9394"]
@@ -2,7 +2,14 @@ services:
2
2
  prometheus:
3
3
  image: prom/prometheus:v3.4.1
4
4
  container_name: <%= app_name %>-prometheus
5
- network_mode: host
5
+ # Published on loopback only, so the dev server is not reachable from other
6
+ # machines. host.docker.internal lets Prometheus scrape the app running on
7
+ # the host, on Linux as well as on macOS and Windows Docker Desktop, where
8
+ # network_mode: host does not reach the host's localhost.
9
+ ports:
10
+ - "127.0.0.1:9090:9090"
11
+ extra_hosts:
12
+ - "host.docker.internal:host-gateway"
6
13
  volumes:
7
14
  - ./config/prometheus/development/prometheus.yml:/etc/prometheus/prometheus.yml:ro
8
15
  - ./config/prometheus/rules:/etc/prometheus/rules:ro
@@ -10,14 +17,16 @@ services:
10
17
  command:
11
18
  - '--config.file=/etc/prometheus/prometheus.yml'
12
19
  - '--storage.tsdb.path=/prometheus'
13
- - '--web.enable-lifecycle'
20
+ # --web.enable-lifecycle is omitted: its unauthenticated reload and quit
21
+ # endpoints are not needed (restart the container to reload config). If
22
+ # you add it back, pair it with --web.config.file authentication.
14
23
  - '--web.enable-remote-write-receiver'
15
24
 
16
25
  alertmanager:
17
26
  image: prom/alertmanager:v0.28.1
18
27
  container_name: <%= app_name %>-alertmanager
19
28
  ports:
20
- - "9093:9093"
29
+ - "127.0.0.1:9093:9093"
21
30
  volumes:
22
31
  - ./config/alertmanager/development/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
23
32
  - alertmanager_data:/alertmanager
@@ -25,14 +34,6 @@ services:
25
34
  - '--config.file=/etc/alertmanager/alertmanager.yml'
26
35
  - '--storage.path=/alertmanager'
27
36
 
28
- playwright:
29
- image: jacoblincool/playwright:chromium-server-1.56.1
30
- container_name: <%= app_name %>-playwright
31
- ports:
32
- - "53333:53333"
33
- environment:
34
- - DEBUG=true
35
-
36
37
  volumes:
37
38
  prometheus_data:
38
39
  alertmanager_data:
@@ -1,8 +1,12 @@
1
- # WARNING: Change the default password before deploying to production!
2
- # Set the ADMIN_PASSWORD environment variable or update the credentials below.
1
+ # Set ADMIN_PASSWORD to enable the built-in admin login. When it is unset the
2
+ # static credential is left unconfigured (fail closed) so the app never ships a
3
+ # well-known default password. Change the username/credentials below to suit, or
4
+ # switch to an external provider (e.g. OpenID Connect) via config.auth_provider.
5
+
6
+ admin_password = ENV["ADMIN_PASSWORD"].presence
3
7
 
4
8
  Rails.application.config.middleware.use OmniAuth::Builder do
5
9
  provider :static_credentials,
6
10
  title: "Sign In",
7
- credentials: { "admin" => ENV.fetch("ADMIN_PASSWORD", "upright") }
11
+ credentials: admin_password ? { "admin" => admin_password } : {}
8
12
  end
@@ -2,6 +2,12 @@ receivers:
2
2
  otlp:
3
3
  protocols:
4
4
  http:
5
+ # The app container reaches the collector over the private Docker
6
+ # network, so the receiver must listen on a non-loopback interface.
7
+ # Kamal does not publish this port to the host (no `port:` on the
8
+ # accessory), so it is only reachable from containers on that network.
9
+ # If you ever publish it, bind a specific internal interface instead
10
+ # of 0.0.0.0 and require authentication.
5
11
  endpoint: 0.0.0.0:4318
6
12
 
7
13
  processors:
@@ -23,3 +23,23 @@ production:
23
23
  cleanup_stale_probe_results:
24
24
  command: "Upright::ProbeResult.cleanup_stale"
25
25
  schedule: every hour at minute 30
26
+
27
+ sweep_playwright_videos:
28
+ class: "Upright::PlaywrightVideoSweepJob"
29
+ schedule: every hour at minute 45
30
+
31
+ health_metrics:
32
+ class: "Upright::HealthMetricsJob"
33
+ schedule: every minute
34
+
35
+ aggregate_rollups:
36
+ class: "Upright::Rollups::DailyAggregationJob"
37
+ schedule: every hour at minute 5
38
+
39
+ advance_maintenances:
40
+ class: "Upright::MaintenanceAdvanceJob"
41
+ schedule: "*/15 * * * * *"
42
+
43
+ report_incidents:
44
+ class: "Upright::IncidentReporterJob"
45
+ schedule: "*/30 * * * * *"
@@ -8,6 +8,8 @@ shared:
8
8
  city: London
9
9
  country: GB
10
10
  geohash: gcpvj0
11
+ stores_metrics: true
12
+ primary: true
11
13
 
12
14
  # Multi-site example:
13
15
  #
@@ -18,6 +20,8 @@ shared:
18
20
  # country: NL
19
21
  # geohash: u17982
20
22
  # provider: digitalocean
23
+ # stores_metrics: true
24
+ # primary: true
21
25
  #
22
26
  # - code: nyc
23
27
  # city: New York City
@@ -5,8 +5,27 @@ Upright.configure do |config|
5
5
  config.user_agent = "<%= Rails.application.class.module_parent_name.underscore %>/1.0"
6
6
  config.hostname = Rails.env.local? ? "<%= Rails.application.class.module_parent_name.underscore.dasherize %>.localhost" : "<%= Rails.application.class.module_parent_name.underscore.dasherize %>.com"
7
7
 
8
- # Playwright browser server URL
9
- # config.playwright_server_url = ENV["PLAYWRIGHT_SERVER_URL"]
8
+ # Playwright CLI path (defaults to "npx playwright", override with PLAYWRIGHT_CLI_PATH env var)
9
+ # config.playwright_cli_path = "npx playwright"
10
+
11
+ # Token authenticating machine callers: collectors writing metrics and jobs
12
+ # reading a peer site's /prometheus and /alertmanager proxies. Randomly
13
+ # generated at install time — every site must share the same value, so treat
14
+ # it like any other secret when adding sites. Set the PROMETHEUS_OTLP_TOKEN
15
+ # env var (already wired as a Kamal secret in config/deploy.yml) to rotate it
16
+ # without editing this file.
17
+ config.proxy_token = ENV.fetch("PROMETHEUS_OTLP_TOKEN", "<%= SecureRandom.hex(32) %>")
18
+
19
+ # Viewer that trace artifacts link to, https://trace.playwright.dev by
20
+ # default. Upright doesn't serve one: the viewer renders a trace's contents as
21
+ # HTML in its own origin, so a viewer on this hostname would let a probe
22
+ # artifact run script against the admin session, and Upright refuses a URL
23
+ # under config.hostname for that reason.
24
+ #
25
+ # Following a link hands the viewer's origin a URL it can read for 24 hours.
26
+ # Point this at a viewer you host to keep traces to yourself, or assign nil to
27
+ # keep them download-only, for `npx playwright show-trace`.
28
+ # config.trace_viewer_url = "https://traces.example.net/index.html"
10
29
 
11
30
  # OpenTelemetry endpoint
12
31
  # config.otel_endpoint = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"]
@@ -6,20 +6,27 @@ groups:
6
6
  interval: 30s
7
7
  rules:
8
8
  # Fraction of regions reporting DOWN (0.0 to 1.0)
9
+ # Keep probe_service so the public status page can read live status per service.
9
10
  - record: upright:probe_down_fraction
10
11
  expr: |
11
- count by (name, type, probe_target, alert_severity) (upright_probe_up == 0)
12
+ (
13
+ count by (name, type, probe_target, alert_severity, probe_service) (upright_probe_up == 0)
14
+ or
15
+ count by (name, type, probe_target, alert_severity, probe_service) (upright_probe_up) * 0
16
+ )
12
17
  /
13
- count by (name, type, probe_target, alert_severity) (upright_probe_up)
18
+ count by (name, type, probe_target, alert_severity, probe_service) (upright_probe_up)
14
19
 
15
20
  # Daily uptime percentage (0.0 to 1.0)
16
21
  # Uptime = percentage of time in past day when majority of sites reported UP
22
+ # Keep probe_service in the grouping so ProbeRollup can tie rollups back
23
+ # to their Service; seed-prometheus emits the same label set.
17
24
  - record: upright:probe_uptime_daily
18
25
  expr: |
19
26
  avg_over_time((
20
- sum by (name, type, probe_target) (upright_probe_up)
27
+ sum by (name, type, probe_target, alert_severity, probe_service) (upright_probe_up)
21
28
  /
22
- count by (name, type, probe_target) (upright_probe_up)
29
+ count by (name, type, probe_target, alert_severity, probe_service) (upright_probe_up)
23
30
  > bool 0.5
24
31
  )[1d:])
25
32
 
@@ -2,6 +2,11 @@ class Upright::Configuration
2
2
  # Global subdomain is always "app" - this is documented behavior
3
3
  GLOBAL_SUBDOMAIN = "app"
4
4
 
5
+ # Public status pages live at status.<hostname>; custom CNAMEs match by subdomain.
6
+ PUBLIC_STATUS_SUBDOMAIN = "status"
7
+
8
+ DEFAULT_TRACE_VIEWER_URL = "https://trace.playwright.dev/"
9
+
5
10
  # Core settings
6
11
  attr_accessor :service_name
7
12
  attr_accessor :user_agent
@@ -10,6 +15,7 @@ class Upright::Configuration
10
15
  # Storage paths
11
16
  attr_accessor :prometheus_dir
12
17
  attr_accessor :video_storage_dir
18
+ attr_accessor :recording_base_dir
13
19
  attr_accessor :storage_state_dir
14
20
  attr_accessor :frozen_record_path
15
21
 
@@ -17,8 +23,13 @@ class Upright::Configuration
17
23
  attr_writer :probes_path
18
24
  attr_writer :authenticators_path
19
25
 
26
+ # Public status page services definition (env-overridable, like probes_path)
27
+ attr_writer :services_path
28
+
20
29
  # Playwright
21
- attr_accessor :playwright_server_url
30
+ attr_accessor :playwright_cli_path
31
+
32
+ attr_reader :trace_viewer_url
22
33
 
23
34
  # Authentication
24
35
  attr_accessor :auth_provider
@@ -29,6 +40,11 @@ class Upright::Configuration
29
40
  attr_accessor :prometheus_url
30
41
  attr_accessor :alert_webhook_url
31
42
 
43
+ attr_writer :proxy_token
44
+
45
+ attr_writer :rollup_minimum_coverage
46
+ attr_writer :rollup_evaluation_interval
47
+
32
48
  # Probe types
33
49
  attr_reader :probe_types
34
50
 
@@ -37,6 +53,15 @@ class Upright::Configuration
37
53
  attr_accessor :stale_failure_threshold
38
54
  attr_accessor :failure_retention_limit
39
55
 
56
+ # Public status pages
57
+ attr_accessor :public_status_enabled
58
+ attr_reader :public_status_custom_domains
59
+
60
+ # Extra stylesheets host apps layer on top of the engine's for the public
61
+ # status page (theming, branding). Logical asset names, loaded last so their
62
+ # :root overrides win the cascade.
63
+ attr_writer :public_stylesheets
64
+
40
65
  def initialize
41
66
  @service_name = "upright"
42
67
  @user_agent = "Upright/1.0"
@@ -44,15 +69,18 @@ class Upright::Configuration
44
69
 
45
70
  @prometheus_dir = nil
46
71
  @video_storage_dir = nil
72
+ @recording_base_dir = nil
47
73
  @storage_state_dir = nil
48
74
  @frozen_record_path = nil
49
75
  @probes_path = nil
50
76
  @authenticators_path = nil
77
+ @services_path = nil
51
78
 
52
79
  @probe_types = Upright::ProbeTypeRegistry.new
53
80
 
54
- @playwright_server_url = ENV["PLAYWRIGHT_SERVER_URL"]
81
+ @playwright_cli_path = ENV.fetch("PLAYWRIGHT_CLI_PATH", "npx playwright")
55
82
  @otel_endpoint = ENV["OTEL_EXPORTER_OTLP_ENDPOINT"]
83
+ @prometheus_url = nil
56
84
 
57
85
  @auth_provider = :static_credentials
58
86
  @auth_options = {}
@@ -60,6 +88,40 @@ class Upright::Configuration
60
88
  @stale_success_threshold = 24.hours
61
89
  @stale_failure_threshold = 30.days
62
90
  @failure_retention_limit = 20_000
91
+
92
+ self.trace_viewer_url = ENV.fetch("TRACE_VIEWER_URL", DEFAULT_TRACE_VIEWER_URL)
93
+
94
+ @public_status_enabled = false
95
+ @public_status_custom_domains = []
96
+ @public_stylesheets = nil
97
+ end
98
+
99
+ def trace_viewer_url=(url)
100
+ @trace_viewer_url = url.presence
101
+ host = trace_viewer_uri&.host
102
+
103
+ if @trace_viewer_url && (host.nil? || admin_domain?(host))
104
+ raise Upright::ConfigurationError, "config.trace_viewer_url must be an http(s) URL outside #{@hostname}"
105
+ end
106
+ end
107
+
108
+ def trace_viewer_origin
109
+ trace_viewer_uri&.then do |url|
110
+ "#{url.scheme}://#{url.host}#{":#{url.port}" unless url.port == url.default_port}"
111
+ end
112
+ end
113
+
114
+ def public_status_subdomain
115
+ PUBLIC_STATUS_SUBDOMAIN
116
+ end
117
+
118
+ def public_stylesheets
119
+ Array(@public_stylesheets)
120
+ end
121
+
122
+ def public_status_custom_domains=(domains)
123
+ @public_status_custom_domains = Array(domains)
124
+ configure_allowed_hosts if @hostname
63
125
  end
64
126
 
65
127
  def global_subdomain
@@ -74,10 +136,30 @@ class Upright::Configuration
74
136
  @prometheus_dir || Rails.root.join("tmp", "prometheus")
75
137
  end
76
138
 
139
+ def prometheus_url
140
+ @prometheus_url || ENV.fetch("PROMETHEUS_URL", "http://localhost:9090")
141
+ end
142
+
143
+ def proxy_token
144
+ @proxy_token || ENV["PROMETHEUS_OTLP_TOKEN"]
145
+ end
146
+
147
+ def rollup_minimum_coverage
148
+ @rollup_minimum_coverage || 0.9
149
+ end
150
+
151
+ def rollup_evaluation_interval
152
+ @rollup_evaluation_interval || 30.seconds
153
+ end
154
+
77
155
  def video_storage_dir
78
156
  @video_storage_dir || Rails.root.join("storage", "playwright_videos")
79
157
  end
80
158
 
159
+ def recording_base_dir
160
+ @recording_base_dir || Rails.root.join("storage", "playwright_recordings")
161
+ end
162
+
81
163
  def storage_state_dir
82
164
  @storage_state_dir || Rails.root.join("storage", "playwright_storage_states")
83
165
  end
@@ -90,6 +172,10 @@ class Upright::Configuration
90
172
  @probes_path || Rails.root.join("probes")
91
173
  end
92
174
 
175
+ def services_path
176
+ @services_path || Rails.root.join("config", "services.yml")
177
+ end
178
+
93
179
  def authenticators_path
94
180
  @authenticators_path || Rails.root.join("probes", "authenticators")
95
181
  end
@@ -104,17 +190,38 @@ class Upright::Configuration
104
190
  end
105
191
 
106
192
  def default_url_options
107
- if Rails.env.production?
108
- { protocol: "https", host: "#{global_subdomain}.#{hostname}", domain: hostname }
109
- else
193
+ if Rails.env.local?
110
194
  { protocol: "http", host: "#{global_subdomain}.#{hostname}", port: ENV.fetch("PORT", 3000).to_i, domain: hostname }
195
+ else
196
+ { protocol: "https", host: "#{global_subdomain}.#{hostname}", domain: hostname }
111
197
  end
112
198
  end
113
199
 
114
200
  private
201
+ def trace_viewer_uri
202
+ url = URI(@trace_viewer_url.to_s)
203
+ url if url.is_a?(URI::HTTP) && url.host.present?
204
+ rescue URI::InvalidURIError
205
+ nil
206
+ end
207
+
208
+ # DNS is case-insensitive and tolerates a trailing dot, so compare folded.
209
+ def admin_domain?(host)
210
+ return false if @hostname.blank?
211
+
212
+ host = host.downcase.chomp(".")
213
+ admin = @hostname.downcase
214
+
215
+ host == admin || host.end_with?(".#{admin}")
216
+ end
217
+
115
218
  def configure_allowed_hosts
116
219
  port_suffix = Rails.env.local? ? "(:\\d+)?" : ""
117
- Rails.application.config.hosts = [ /.*\.#{Regexp.escape(hostname)}#{port_suffix}/, /#{Regexp.escape(hostname)}#{port_suffix}/ ]
220
+ hosts = [ /.*\.#{Regexp.escape(hostname)}#{port_suffix}/, /#{Regexp.escape(hostname)}#{port_suffix}/ ]
221
+ Array(@public_status_custom_domains).each do |domain|
222
+ hosts << /\A#{Regexp.escape(domain)}#{port_suffix}\z/
223
+ end
224
+ Rails.application.config.hosts = hosts
118
225
  Rails.application.config.action_dispatch.tld_length = 1
119
226
  end
120
227
  end
@@ -6,14 +6,34 @@ class Upright::Engine < ::Rails::Engine
6
6
 
7
7
  # Session store configuration
8
8
  initializer "upright.session_store", before: :load_config_initializers do |app|
9
+ # Scope the cookie to the configured hostname (resolved per-request, since the
10
+ # host app sets it in an initializer that runs after this one) rather than
11
+ # `domain: :all`, which — with a multi-label hostname like upright.example.com
12
+ # — would hand the cookie to the registrable parent and every sibling under it
13
+ # (F-08). This is independent of tld_length, so custom status domains still
14
+ # parse normally.
9
15
  app.config.session_store :cookie_store,
10
16
  key: "_upright_session",
11
- domain: :all,
17
+ domain: ->(_request) { Upright.configuration.hostname&.then { |h| ".#{h}" } },
12
18
  same_site: :lax,
13
- secure: Rails.env.production?,
19
+ secure: !Rails.env.local?,
20
+ httponly: true,
14
21
  expire_after: 24.hours
15
22
  end
16
23
 
24
+ # The proxy token stands in for an admin session on the metrics proxies, so a
25
+ # blank one in a deployed environment is a fail-open: any bearer value would be
26
+ # compared against "" . Refuse to boot without one outside local envs — but not
27
+ # during `assets:precompile`, which Rails runs with SECRET_KEY_BASE_DUMMY and no
28
+ # runtime secrets (the Docker image builds before the token is available).
29
+ config.after_initialize do
30
+ building_assets = ENV["SECRET_KEY_BASE_DUMMY"].present?
31
+
32
+ if !Rails.env.local? && !building_assets && Upright.configuration.proxy_token.blank?
33
+ raise Upright::ConfigurationError, "Upright.configuration.proxy_token (or PROMETHEUS_OTLP_TOKEN) must be set outside development/test"
34
+ end
35
+ end
36
+
17
37
  config.after_initialize do
18
38
  url_options = Upright.configuration.default_url_options
19
39
  Rails.application.routes.default_url_options = url_options
@@ -125,6 +145,9 @@ class Upright::Engine < ::Rails::Engine
125
145
  Upright.sites.each do |site|
126
146
  puts " #{site.city || site.code}: #{protocol}://#{site.code}.#{hostname}:#{port}"
127
147
  end
148
+ if Upright.configuration.public_status_enabled
149
+ puts " Public status: #{protocol}://#{Upright.configuration.public_status_subdomain}.#{hostname}:#{port}"
150
+ end
128
151
  puts ""
129
152
  end
130
153
  end
@@ -55,6 +55,31 @@ module Upright::Metrics
55
55
  comment: "HTTP response status code",
56
56
  aggregation: :max,
57
57
  tags: %i[name probe_target probe_service]
58
+
59
+ gauge :service_under_maintenance,
60
+ comment: "Whether a service is under active maintenance (1 = yes, 0 = no)",
61
+ aggregation: :most_recent,
62
+ tags: %i[probe_service]
63
+
64
+ gauge :primary_site,
65
+ comment: "Whether this site is the primary (1 = yes, 0 = no)",
66
+ aggregation: :most_recent,
67
+ tags: []
68
+
69
+ gauge :persistent_db_up,
70
+ comment: "Whether the persistent database answers (1 = yes, 0 = no)",
71
+ aggregation: :most_recent,
72
+ tags: []
73
+
74
+ gauge :rollup_skipped_probes,
75
+ comment: "Probes the last rollup run left unwritten for insufficient Prometheus coverage",
76
+ aggregation: :most_recent,
77
+ tags: []
78
+
79
+ gauge :rollup_last_run_timestamp_seconds,
80
+ comment: "Unix time of the most recently written daily rollup",
81
+ aggregation: :max,
82
+ tags: []
58
83
  end
59
84
  end
60
85
  end
data/lib/upright/site.rb CHANGED
@@ -2,19 +2,37 @@ module Upright
2
2
  class Site
3
3
  attr_reader :code, :city, :country, :geohash, :stagger_index
4
4
 
5
- def initialize(code:, city: nil, country: nil, geohash: nil, provider: nil, stagger_index: 0)
5
+ def initialize(code:, city: nil, country: nil, geohash: nil, provider: nil, stores_metrics: false, primary: false, stagger_index: 0)
6
6
  @code = code.to_sym
7
7
  @city = city
8
8
  @country = country
9
9
  @geohash = geohash
10
10
  @provider = provider
11
+ @stores_metrics = stores_metrics
12
+ @primary = primary
11
13
  @stagger_index = stagger_index
12
14
  end
13
15
 
16
+ def stores_metrics?
17
+ @stores_metrics
18
+ end
19
+
20
+ def primary?
21
+ @primary
22
+ end
23
+
14
24
  def host
15
25
  URI.parse(url).host
16
26
  end
17
27
 
28
+ def current?
29
+ code == Upright.current_site&.code
30
+ end
31
+
32
+ def prometheus_client
33
+ current? ? Upright.prometheus_client : peer_prometheus_client
34
+ end
35
+
18
36
  def provider
19
37
  @provider.to_s.inquiry
20
38
  end
@@ -40,6 +58,18 @@ module Upright
40
58
  end
41
59
 
42
60
  private
61
+ def peer_prometheus_client
62
+ Prometheus::ApiClient.client(url: prometheus_proxy_url, headers: proxy_authorization, options: { timeout: 30 })
63
+ end
64
+
65
+ def prometheus_proxy_url
66
+ "#{url.chomp("/")}/prometheus"
67
+ end
68
+
69
+ def proxy_authorization
70
+ { "Authorization" => "Bearer #{Upright.configuration.proxy_token}" }
71
+ end
72
+
43
73
  def coordinates
44
74
  @coordinates ||= Upright::Geohash.decode(geohash).first
45
75
  end
@@ -1,3 +1,5 @@
1
1
  module Upright
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
+ # Keep in sync with the playwright-ruby-client constraint in upright.gemspec
4
+ PLAYWRIGHT_VERSION = "1.59"
3
5
  end
data/lib/upright.rb CHANGED
@@ -13,6 +13,7 @@ require "propshaft"
13
13
  require "importmap-rails"
14
14
  require "turbo-rails"
15
15
  require "stimulus-rails"
16
+ require "local_time"
16
17
  require "geared_pagination"
17
18
  require "yabeda/prometheus"
18
19
  require "yabeda/puma/plugin"
@@ -43,6 +44,13 @@ module Upright
43
44
  configuration.probe_types
44
45
  end
45
46
 
47
+ def prometheus_client
48
+ Prometheus::ApiClient.client(
49
+ url: configuration.prometheus_url,
50
+ options: { timeout: 30.seconds }
51
+ )
52
+ end
53
+
46
54
  def sites
47
55
  @sites ||= load_sites
48
56
  end
@@ -52,7 +60,15 @@ module Upright
52
60
  end
53
61
 
54
62
  def current_site
55
- find_site(ENV["SITE_SUBDOMAIN"]) || sites.first
63
+ if (subdomain = ENV["SITE_SUBDOMAIN"]).present?
64
+ find_site(subdomain) || raise(ConfigurationError, "SITE_SUBDOMAIN=#{subdomain} is not a site in sites.yml (#{sites.map(&:code).join(', ')})")
65
+ else
66
+ sites.first
67
+ end
68
+ end
69
+
70
+ def primary_site
71
+ sites.find(&:primary?)
56
72
  end
57
73
 
58
74
  private
@@ -64,10 +80,21 @@ module Upright
64
80
 
65
81
  config[:sites].map.with_index do |site_config, index|
66
82
  Site.new(stagger_index: index, **site_config)
67
- end
83
+ end.tap { |sites| ensure_at_most_one_primary(sites) }
68
84
  else
69
85
  []
70
86
  end
71
87
  end
88
+
89
+ # Two primaries means two hosts running the singleton jobs that write the
90
+ # shared persistent database. None is how a host looks before it adopts
91
+ # the flag, so that stays legal.
92
+ def ensure_at_most_one_primary(sites)
93
+ primaries = sites.select(&:primary?)
94
+
95
+ if primaries.many?
96
+ raise ConfigurationError, "sites.yml declares #{primaries.count} primary sites (#{primaries.map(&:code).join(', ')}); at most one may be primary"
97
+ end
98
+ end
72
99
  end
73
100
  end