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.
- checksums.yaml +4 -4
- data/README.md +66 -7
- data/Rakefile +22 -0
- data/app/assets/images/upright/.keep +0 -0
- data/app/assets/stylesheets/upright/_global.css +2 -0
- data/app/assets/stylesheets/upright/artifact.css +7 -25
- data/app/assets/stylesheets/upright/flash.css +19 -0
- data/app/assets/stylesheets/upright/header.css +14 -0
- data/app/assets/stylesheets/upright/incidents.css +610 -0
- data/app/assets/stylesheets/upright/map.css +6 -0
- data/app/assets/stylesheets/upright/public_status.css +534 -0
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/concerns/upright/authentication.rb +1 -0
- data/app/controllers/concerns/upright/proxy_authentication.rb +134 -0
- data/app/controllers/upright/alertmanager_proxy_controller.rb +12 -4
- data/app/controllers/upright/artifacts_controller.rb +1 -1
- data/app/controllers/upright/incidents/updates_controller.rb +13 -0
- data/app/controllers/upright/incidents_controller.rb +53 -0
- data/app/controllers/upright/prometheus_proxy_controller.rb +13 -18
- data/app/controllers/upright/public/base_controller.rb +16 -0
- data/app/controllers/upright/public/incidents_controller.rb +6 -0
- data/app/controllers/upright/public/service_incidents_controller.rb +7 -0
- data/app/controllers/upright/public/services_controller.rb +6 -0
- data/app/controllers/upright/sessions_controller.rb +36 -1
- data/app/controllers/upright/traces_controller.rb +88 -0
- data/app/helpers/upright/application_helper.rb +22 -2
- data/app/helpers/upright/artifacts_helper.rb +20 -0
- data/app/helpers/upright/probe_results_helper.rb +1 -0
- data/app/helpers/upright/public/services_helper.rb +70 -0
- data/app/javascript/upright/application.js +3 -0
- data/app/javascript/upright/controllers/sites_map_controller.js +13 -5
- data/app/javascript/upright/controllers/template_controller.js +11 -0
- data/app/javascript/upright/public.js +5 -0
- data/app/jobs/upright/health_metrics_job.rb +27 -0
- data/app/jobs/upright/incident_reporter_job.rb +7 -0
- data/app/jobs/upright/maintenance_advance_job.rb +8 -0
- data/app/jobs/upright/playwright_video_sweep_job.rb +21 -0
- data/app/jobs/upright/rollups/daily_aggregation_job.rb +14 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/upright/incidents/auto_reporting.rb +93 -0
- data/app/models/concerns/upright/incidents/lifecycle.rb +47 -0
- data/app/models/concerns/upright/playwright/form_authentication.rb +3 -2
- data/app/models/concerns/upright/playwright/lifecycle.rb +8 -13
- data/app/models/concerns/upright/playwright/trace_recording.rb +53 -0
- data/app/models/concerns/upright/playwright/video_recording.rb +55 -14
- data/app/models/concerns/upright/probe_result/stale_cleanup.rb +26 -2
- data/app/models/concerns/upright/probeable.rb +9 -1
- data/app/models/concerns/upright/services/live_status.rb +69 -0
- data/app/models/concerns/upright/services/maintenance_status.rb +19 -0
- data/app/models/upright/artifact.rb +2 -1
- data/app/models/upright/http/request.rb +12 -3
- data/app/models/upright/incident/automatic_report.rb +3 -0
- data/app/models/upright/incident.rb +87 -0
- data/app/models/upright/incident_affected_service.rb +9 -0
- data/app/models/upright/incident_update.rb +7 -0
- data/app/models/upright/maintenance.rb +36 -0
- data/app/models/upright/persistent_record.rb +12 -0
- data/app/models/upright/playwright/authenticator/base.rb +50 -70
- data/app/models/upright/probes/http_probe.rb +30 -1
- data/app/models/upright/probes/playwright/base.rb +2 -0
- data/app/models/upright/probes/status/probe.rb +8 -0
- data/app/models/upright/probes/status.rb +3 -11
- data/app/models/upright/probes/uptime.rb +2 -9
- data/app/models/upright/rollups/daily_uptime.rb +28 -0
- data/app/models/upright/rollups/probe_rollup.rb +64 -0
- data/app/models/upright/rollups/probe_uptime.rb +9 -0
- data/app/models/upright/rollups/site_uptime.rb +75 -0
- data/app/models/upright/service/daily_status.rb +39 -0
- data/app/models/upright/service/incident_history.rb +24 -0
- data/app/models/upright/service/outage.rb +15 -0
- data/app/models/upright/service/status_page.rb +35 -0
- data/app/models/upright/service.rb +101 -0
- data/app/models/upright/status.rb +21 -0
- data/app/models/upright/traceroute/ip_metadata_lookup.rb +21 -2
- data/app/models/upright/traceroute/result.rb +8 -1
- data/app/views/layouts/upright/_header.html.erb +12 -2
- data/app/views/layouts/upright/application.html.erb +4 -1
- data/app/views/layouts/upright/public.html.erb +18 -0
- data/app/views/layouts/upright/public.rss.builder +1 -0
- data/app/views/upright/active_storage/attachments/_attachment.html.erb +1 -1
- data/app/views/upright/artifacts/show.html.erb +8 -0
- data/app/views/upright/incidents/_form.html.erb +93 -0
- data/app/views/upright/incidents/_row.html.erb +15 -0
- data/app/views/upright/incidents/edit.html.erb +64 -0
- data/app/views/upright/incidents/index.html.erb +53 -0
- data/app/views/upright/incidents/new.html.erb +6 -0
- data/app/views/upright/public/_branding.html.erb +3 -0
- data/app/views/upright/public/_footer.html.erb +3 -0
- data/app/views/upright/public/incidents/show.html.erb +29 -0
- data/app/views/upright/public/service_incidents/_incident.html.erb +14 -0
- data/app/views/upright/public/service_incidents/index.html.erb +50 -0
- data/app/views/upright/public/services/_active_events.html.erb +28 -0
- data/app/views/upright/public/services/_degraded_list.html.erb +14 -0
- data/app/views/upright/public/services/_overall_banner.html.erb +4 -0
- data/app/views/upright/public/services/_service.html.erb +34 -0
- data/app/views/upright/public/services/_upcoming_maintenance.html.erb +21 -0
- data/app/views/upright/public/services/_uptime_bar.html.erb +9 -0
- data/app/views/upright/public/services/index.html.erb +13 -0
- data/app/views/upright/public/services/index.rss.builder +29 -0
- data/app/views/upright/sessions/new.html.erb +14 -1
- data/config/importmap.rb +25 -14
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/mime_types.rb +1 -0
- data/config/routes.rb +26 -2
- data/db/migrate/20260629000001_fix_probe_result_duration_precision.rb +14 -0
- data/db/persistent_migrate/20260512000001_create_upright_rollups.rb +16 -0
- data/db/persistent_migrate/20260702000001_create_upright_incidents.rb +34 -0
- data/db/persistent_migrate/20260707000001_add_probe_identity_to_upright_rollups.rb +11 -0
- data/db/persistent_migrate/20260709000001_add_created_by_to_incidents.rb +7 -0
- data/db/persistent_migrate/20260901000001_add_auto_reporting_to_upright_incidents.rb +22 -0
- data/lib/generators/upright/install/database_config.rb +46 -0
- data/lib/generators/upright/install/install_generator.rb +18 -10
- data/lib/generators/upright/install/templates/Dockerfile +80 -0
- data/lib/generators/upright/install/templates/content_security_policy.rb +42 -0
- data/lib/generators/upright/install/templates/deploy.yml +13 -13
- data/lib/generators/upright/install/templates/development_prometheus.yml +1 -1
- data/lib/generators/upright/install/templates/docker-compose.yml +12 -11
- data/lib/generators/upright/install/templates/omniauth.rb +7 -3
- data/lib/generators/upright/install/templates/otel_collector.yml +6 -0
- data/lib/generators/upright/install/templates/recurring.yml +20 -0
- data/lib/generators/upright/install/templates/sites.yml +4 -0
- data/lib/generators/upright/install/templates/upright.rb +21 -2
- data/lib/generators/upright/install/templates/upright.rules.yml +11 -4
- data/lib/upright/configuration.rb +113 -6
- data/lib/upright/engine.rb +25 -2
- data/lib/upright/metrics.rb +25 -0
- data/lib/upright/site.rb +31 -1
- data/lib/upright/version.rb +3 -1
- data/lib/upright.rb +29 -2
- metadata +107 -7
- data/config/credentials/development.key +0 -1
- data/config/credentials/test.key +0 -1
- 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
|
-
|
|
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
|
|
@@ -2,7 +2,14 @@ services:
|
|
|
2
2
|
prometheus:
|
|
3
3
|
image: prom/prometheus:v3.4.1
|
|
4
4
|
container_name: <%= app_name %>-prometheus
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
2
|
-
#
|
|
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" =>
|
|
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
|
|
9
|
-
# config.
|
|
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
|
-
|
|
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 :
|
|
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
|
-
@
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/upright/engine.rb
CHANGED
|
@@ -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:
|
|
17
|
+
domain: ->(_request) { Upright.configuration.hostname&.then { |h| ".#{h}" } },
|
|
12
18
|
same_site: :lax,
|
|
13
|
-
secure: Rails.env.
|
|
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
|
data/lib/upright/metrics.rb
CHANGED
|
@@ -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
|
data/lib/upright/version.rb
CHANGED
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
|
-
|
|
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
|