Synthetic monitoring engine with Playwright and Prometheus metrics
Synthetic monitoring engine with Playwright and Prometheus metrics
Upright is a self-hosted synthetic monitoring system. It provides a framework for running health check probes from multiple geographic sites and reporting metrics via Prometheus. Alerts can then be configured with AlertManager.
Site overview with world map
30-day uptime history
Probe status across all sites
[!NOTE] Upright is designed to be run in its own Rails app and deployed with Kamal.
Create a new Rails application and install Upright:
rails new my-upright --database=sqlite3 --skip-test
cd my-upright
bundle add upright
bin/rails generate upright:install
bin/rails db:prepare
Start the server:
bin/dev
Visit http://app.my-upright.localhost:3000 to see your Upright instance.
Note: Upright uses subdomain-based routing. The
appsubdomain is the admin interface, while site-specific subdomains (e.g.,nyc,lon) show probe results for each location. The.localhostTLD resolves to 127.0.0.1 on most systems.
The upright:install generator creates:
config/database.yml - Development and test split into primary (probe results), persistent (rollups and incidents, migrated from the gem) and queue (Solid Queue) databasesconfig/recurring.yml - Probe schedules and the engine's housekeeping, health, rollup, incident and maintenance jobsconfig/initializers/upright.rb - Engine configurationconfig/initializers/content_security_policy.rb - Ready-to-enable CSP covering the engine's CDN dependenciesconfig/sites.yml - Site definitions for each VPS you host Upright onconfig/prometheus/prometheus.yml - Prometheus configurationconfig/alertmanager/alertmanager.yml - AlertManager configurationconfig/otel_collector.yml - OpenTelemetry Collector configurationconfig/deploy.yml and Dockerfile - Kamal deployment, with Prometheus and Alertmanager pinned by image digest.kamal/secrets entries and .kamal/hooks/pre-deploy - The machine tokens and admin password as Kamal secrets, and a hook that refuses to deploy while a token is missing (see Machine tokens)probes/ - Directory for all HTTP, SMTP, Traceroute YAML config as well as Playwright probe classesIt also mounts the engine at / in your routes.
See config/initializers/upright.rb
Upright uses subdomain-based routing. Configure your production hostname:
# config/initializers/upright.rb
Upright.configure do |config|
config.hostname = "upright.com"
end
For local development, the hostname defaults to {service_name}.localhost (e.g., upright.localhost).
Define your monitoring locations in config/sites.yml:
shared:
sites:
- code: nyc
city: New York City
country: US
geohash: dr5reg
provider: digitalocean
- code: ams
city: Amsterdam
country: NL
geohash: u17982
provider: digitalocean
stores_metrics: true
primary: true
- code: sfo
city: San Francisco
country: US
geohash: 9q8yy
provider: hetzner
Each site node identifies itself via the SITE_SUBDOMAIN environment variable, configured in your Kamal deploy.yml.
Two optional flags give a site a role beyond running probes: primary serves the app and status hostnames and runs the jobs writing the shared database, and stores_metrics runs a local Prometheus and Alertmanager. See Sites and their roles for what each one changes, the health metrics every site exports, and how daily rollups read across them.
Two bearer tokens authenticate machine callers on the /prometheus and /alertmanager proxies. Each does one job:
| Env var | Config | Who presents it | What it allows |
|---|---|---|---|
PROMETHEUS_OTLP_TOKEN |
config.otlp_token |
The OpenTelemetry collector on every site | POST /prometheus/api/v1/otlp/v1/metrics, and nothing else |
METRICS_READ_TOKEN |
config.metrics_read_token |
Peer sites computing rollups, and tooling | GET and HEAD on /prometheus and /alertmanager, except the /-/ lifecycle endpoints |
Generate each with bin/rails secret. Set them as Kamal secrets, the same two values on every site, and nowhere in git. Outside development and test the app refuses to boot when either is missing or when both hold the same value, and .kamal/hooks/pre-deploy refuses to deploy for the same reasons before any container is replaced.
export PROMETHEUS_OTLP_TOKEN=$(bin/rails secret)
export METRICS_READ_TOKEN=$(bin/rails secret)
bin/kamal deploy
A token cannot reach any other route. The admin UI uses a session cookie instead.
Upright uses static credentials by default with username admin and the password taken from the ADMIN_PASSWORD environment variable.
[!IMPORTANT] There is no default password:
ADMIN_PASSWORDmust be set, and sign-in fails closed without it. Set it as a Kamal secret in production (the generatedconfig/deploy.ymlalready lists it) and export it locally for development.
For production environments, Upright supports OpenID Connect (Logto, Keycloak, Duo, Okta, etc.):
# config/initializers/upright.rb
Upright.configure do |config|
config.auth_provider = :openid_connect
config.auth_options = {
issuer: "https://your-tenant.logto.app/oidc",
client_id: ENV["OIDC_CLIENT_ID"],
client_secret: ENV["OIDC_CLIENT_SECRET"]
}
end
Upright automatically cleans up old probe results on a recurring schedule. You can configure the retention thresholds:
Upright.configure do |config|
config.stale_success_threshold = 24.hours # Delete successful results older than this (default: 24 hours)
config.stale_failure_threshold = 30.days # Delete failed results older than this (default: 30 days)
config.failure_retention_limit = 20_000 # Keep at most this many failed results (default: 20,000)
end
Upright ships with four built-in probe types: HTTP, Playwright, SMTP, and Traceroute. You can register your own to extend the system.
Add it to your initializer so Upright knows about its name and icon:
# config/initializers/upright.rb
Upright.configure do |config|
config.probe_types.register :ping, name: "Ping", icon: ""
end
Add a Ruby class in your probes/ directory that extends FrozenRecord::Base and includes Upright::Probeable and Upright::ProbeYamlSource. Implement probe_type, probe_target, check, and on_check_recorded:
…
config/deploy.yml reads RAILS_MASTER_KEY, PROMETHEUS_OTLP_TOKEN, METRICS_READ_TOKEN and ADMIN_PASSWORD from .kamal/secrets, which the generator points at environment variables of the same names. Kamal passes an empty value through when a variable is unset, so .kamal/hooks/pre-deploy checks the resolved secrets Kamal hands it and stops the deploy when either token is empty or both are the same. The app applies the same checks at boot. See Machine tokens.
Metrics are exposed via a Puma plugin at http://0.0.0.0:9394/metrics. Configure Prometheus to scrape:
scrape_configs:
- job_name: upright
static_configs:
- targets: ['localhost:9394']
upright_probe_duration_seconds - Probe execution durationupright_probe_up - Probe status (1 = up, 0 = down)upright_http_response_status - HTTP response status codeLabels include: type, name, site_code, site_city, site_country
Example alert rules (prometheus/rules/upright.rules):
groups:
- name: upright
rules:
- alert: ProbeDown
expr: upright_probe_up == 0
for: 5m
labels:
severity: "{{ $labels.alert_severity }}"
annotations:
summary: "Probe {{ $labels.name }} is down"
Traces are automatically created for each probe execution. Configure your collector endpoint:
Upright.configure do |config|
config.otel_endpoint = "https://otel.example.com:4318"
end
bin/setup
This installs dependencies, prepares the database, and starts the dev server.
Start supporting Docker services (Playwright server, etc.):
bin/services
bin/dev
Visit http://app.upright.localhost:3000 and sign in with:
adminADMIN_PASSWORD env var, e.g. ADMIN_PASSWORD=upright bin/dev (a throwaway value like this is fine for local testing only — never for a deployed site)Run probes with a visible browser window:
HEADLESS=false bin/rails console
Probes::Playwright::MyServiceAuthProbe.check
Playwright versions are pinned via Upright::PLAYWRIGHT_VERSION in lib/upright/version.rb. This drives the Ruby gem and npm package versions. To upgrade:
PLAYWRIGHT_VERSION in lib/upright/version.rbpackage.jsonbin/setup (or manually: npm install && npx playwright install chromium)bin/rails test to verify compatibilitypackage.json and package-lock.jsonUpright stores a Playwright trace as a probe result artifact and does not serve the Playwright Trace Viewer. The viewer renders a trace's tags and attributes as HTML in its own origin, so serving it from Upright's hostname would let a trace run script against an admin session.
A trace artifact links to config.trace_viewer_url, which defaults to
upstream's hosted viewer at https://trace.playwright.dev. That viewer runs
entirely in the browser, and being on its own registrable domain is what keeps a
trace's contents off Upright's origin.
Following the link hands that origin a URL it can read for 24 hours. Set
config.trace_viewer_url to a viewer you host to keep traces to yourself, or
assign nil to keep them download-only:
npx playwright show-trace trace.zip
A URL under config.hostname raises Upright::ConfigurationError, since that
would put the trace back on the admin origin.
That trace URL is served by Upright::TracesController, not Active Storage. The
viewer fetches it from its own or
No open issues yet, or sync has not completed.