Synthetic browser monitoring for Ruby. Give it a URL; get back a structured report of what a Chromium browser saw — HTTP status, broken assets, JavaScript errors, and load time. No framework required.
report=Perchfall.run(url: "https://example.com")report.ok?# => truereport.http_status# => 200report.duration_ms# => 834report.network_errors# => []report.console_errors# => []report.to_json# => '{"status":"ok","url":"https://example.com",...}'Uptime monitoring tells you a server is responding. Perchfall tells you the page actually works.
- A
200 OKdoesn't mean your JavaScript loaded. - An APM trace doesn't capture a missing CDN asset.
- A health check endpoint doesn't know your checkout flow is broken.
Perchfall runs a headless Chromium browser against your URL and gives you back everything it found: the HTTP status, every failed network request, every JavaScript error logged to the console, and how long it took. The result is an immutable Ruby value object you can store, log, or alert on — no database schema imposed, no framework lock-in.
Drop it into a Sidekiq job, a Rake task, a CI step, or a plain Ruby script. It works anywhere Ruby runs.
| Dependency | Version |
|---|---|
| Ruby | ≥ 3.2 |
| Node | ≥ 18 |
| Playwright | installed via npm |
# 1. Add the gem
bundle add perchfall
# 2. Install Playwright (once per machine)
npm install playwright
npx playwright install chromiumrequire"perchfall"report=Perchfall.run(url: "https://example.com")ifreport.ok?puts"#{report.url} loaded in #{report.duration_ms}ms"elseputs"Page failed: #{report.network_errors.map(&:failure).join(", ")}"endreport=Perchfall.run(url: "https://example.com")report.network_errors.eachdo |e|
puts"#{e.http_method}#{e.url} — #{e.failure}"end# GET https://example.com/assets/app.js — HTTP 404# GET https://cdn.example.com/font.woff — net::ERR_NAME_NOT_RESOLVEDreport.console_errors.eachdo |e|
puts"#{e.type}: #{e.text}"end# error: Uncaught ReferenceError: Stripe is not definedA page can be ok: true (it loaded) and still have broken sub-resources. Perchfall captures both.
beginreport=Perchfall.run(url: "https://example.com",timeout_ms: 10_000)rescuePerchfall::Errors::PageLoadError=>e# Page couldn't load at all — a partial report is always attached.store_report(e.report.to_json)endclassSyntheticCheckJobincludeSidekiq::Jobdefperform(url)report=Perchfall.run(url: url)SyntheticResult.create!(ok: report.ok?,payload: report.to_json)rescuePerchfall::Errors::PageLoadError=>eSyntheticResult.create!(ok: false,payload: e.report.to_json)endendEvery check returns a Perchfall::Report:
| Field | Type | Description |
|---|---|---|
ok? | Boolean | true if the page loaded successfully |
http_status | Integer / nil | HTTP response code |
duration_ms | Integer | Total time from navigation start to load event |
url | String | The URL checked |
timestamp | Time | When the check ran (UTC) |
cache_profile | Symbol / nil | Cache profile used (:query_bust, :warm, :no_cache, :no_store) |
network_errors | Array | Failed or errored network requests |
console_errors | Array | JavaScript errors logged to the browser console |
to_json | String | Full report as JSON |
→ Full report schema and JSON reference
| Exception | When |
|---|---|
ArgumentError | URL is invalid (bad scheme, internal address) |
Perchfall::Errors::PageLoadError | Page couldn't load; partial report attached at e.report |
Perchfall::Errors::ConcurrencyLimitError | All browser slots are busy; back off and retry |
Perchfall::Errors::InvocationError | Node isn't installed or not in PATH |
Perchfall::Errors::Error | Base class — catches any Perchfall error |
Perchfall.run(url: "https://example.com",timeout_ms: 10_000,# default 30_000, max 60_000wait_until: "domcontentloaded",# default "load"scenario_name: "homepage_smoke",# included in report JSONcache_profile: :no_cache,# default :query_bustretries: 2,# default 0 (off); opt-in retry of transient failuresretry_on: [:load_error,:script_error,:server_error],# defaultretry_backoff_ms: 250# default 250 (exponential: 250, 500, 1000…))Small timing blips — a navigation that times out by a hair, a connection reset, a server returning 5xx mid-restart — can fail a check that would pass on a second look. Retries are opt-in; you declare which conditions are worth retrying.
# Up to 2 extra attempts on load/script/5xx failures (the defaults)Perchfall.run(url: "https://example.com",retries: 2)Load failures, process failures (:script_error), and HTTP 5xx (:server_error)
are retried by default; :client_error (4xx) and :network_error are available
but off. JavaScript/console (assertion) errors are never retried — they are
real defects, not timing blips. A failure is retried only when every reason it
failed is a declared condition. ConcurrencyLimitError is not auto-retried —
it's a back-pressure signal to back off at the caller level.
→ All options, cache profiles, retries, and wait_until strategies
- Rails integration — Sidekiq job, schema, scheduling
- Security — SSRF protection, URL validation, ignore rules
- Architecture decisions
bundle install
bundle exec rspec # ~0.5s, no browser or Node required (292 examples)
RUN_JS_SPECS=true bundle exec rspec # includes check.js integration specs (310 examples)
bundle exec rubocop # lint; enforced in CI
bin/console # IRB with perchfall loadedBug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md.
Everyone interacting in the Perchfall project's codebases, issue trackers, and discussions is expected to follow the code of conduct.
Released under the MIT License.