Perchfall
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? # => true
report.http_status # => 200
report.duration_ms # => 834
report.network_errors # => []
report.console_errors # => []
report.to_json # => '{"status":"ok","url":"https://example.com",...}'Why Perchfall
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.
Requirements
| Dependency | Version |
|---|---|
| Ruby | ≥ 3.2 |
| Node | ≥ 18 |
| Playwright | installed via npm |
Installation
# 1. Add the gem
bundle add perchfall
# 2. Install Playwright (once per machine)
npm install playwright
npx playwright install chromiumQuickstart
require "perchfall"
report = Perchfall.run(url: "https://example.com")
if report.ok?
puts "#{report.url} loaded in #{report.duration_ms}ms"
else
puts "Page failed: #{report.network_errors.map(&:failure).join(", ")}"
endDetect broken assets and JS errors
report = Perchfall.run(url: "https://example.com")
report.network_errors.each do |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_RESOLVED
report.console_errors.each do |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.
Handle page load failures
begin
report = Perchfall.run(url: "https://example.com", timeout_ms: 10_000)
rescue Perchfall::Errors::PageLoadError => e
# Page couldn't load at all — a partial report is always attached.
store_report(e.report.to_json)
endUse in a background job
class SyntheticCheckJob
include Sidekiq::Job
def perform(url)
report = Perchfall.run(url: url)
SyntheticResult.create!(ok: report.ok?, payload: report.to_json)
rescue Perchfall::Errors::PageLoadError => e
SyntheticResult.create!(ok: false, payload: e.report.to_json)
end
endWhat's in a report
Every 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
Errors
| 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 |
Configuration
Perchfall.run(
url: "https://example.com",
timeout_ms: 10_000, # default 30_000, max 60_000
wait_until: "domcontentloaded", # default "load"
scenario_name: "homepage_smoke", # included in report JSON
cache_profile: :no_cache, # default :query_bust
retries: 2, # default 0 (off); opt-in retry of transient failures
retry_on: [:load_error, :script_error, :server_error], # default
retry_backoff_ms: 250 # default 250 (exponential: 250, 500, 1000…)
)Retry transient failures
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
Further reading
- Rails integration — Sidekiq job, schema, scheduling
- Security — SSRF protection, URL validation, ignore rules
- Architecture decisions
Development
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 loadedContributing
Bug reports and pull requests are welcome on GitHub. See CONTRIBUTING.md.
Code of Conduct
Everyone interacting in the Perchfall project's codebases, issue trackers, and discussions is expected to follow the code of conduct.
License
Released under the MIT License.