Monitoring
Proxy monitors
A scraper is only as healthy as its egress. A proxy monitor sends real requests through your proxies and tells you which one is failing and why: the proxy, the provider account, or the target.
How it works
- For every proxy in the pool, each check sends
samplesrequests to the target through that proxy: absolute-form orCONNECTforhttp://andhttps://proxies, the RFC 1928 handshake forsocks5://. The proxy resolves the target's name, as it does for your own traffic. - Each request is timed (connect to the proxy, tunnel, first byte, total) and, when it fails, given a failure class.
- The proxy is healthy when at least
minSuccessrequests succeeded and the exit expectations hold (country, distinct IPs, latency). - The monitor is down when fewer than
minHealthyPctpercent of the proxies are healthy, and degraded while any of them is failing. The usual confirmation applies (failureThreshold, default 2 checks).
curl -X POST https://upbutler.com/api/v1/monitors \
-H "Authorization: Bearer $UPBUTLER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Evomi residential",
"proxy": {
"url": "http://'"$EVOMI_USER"':'"$EVOMI_PASS"'_country-US_session-{session}@rp.evomi.com:1000",
"samples": 10, "minSuccess": 8, "minDistinctIps": 5, "expectCountry": "US"
}
}'{
"_id": "mon_0n3qc1a2b3c4d5e6f7g",
"kind": "proxy",
"target": "http proxy via rp.evomi.com",
"proxy": {
"entries": [
{ "id": "rp-evomi-com-1000", "name": "rp.evomi.com:1000", "scheme": "http", "host": "rp.evomi.com", "port": 1000,
"hasCredentials": true, "credentialHint": "fet…", "rotatesSession": true, "provider": "Evomi" }
],
"target": { "url": "https://upbutler.com/api/v1/echo/ip", "echo": true, "method": "GET", "expectedStatus": "200-299" },
"samples": 10, "minSuccess": 8,
"expect": { "country": "US", "minDistinctIps": 5 },
"minHealthyPct": 50
}
}The target
By default the target is UpButler's own IP echo, GET /api/v1/echo/ip, which answers {"ip": "…", "country": "US"}: the exit IP and country of the request, so rotation and country rules work without configuration. Set target to fetch something else: the site you scrape (to notice blocks), or your provider's echo (https://ip.evomi.com/s, https://geo.brdtest.com/mygeo.json, https://ip.oxylabs.io/location, https://ip.decodo.com/json, https://ipv4.webshare.io/). Exit IP and country are read from plain-text and JSON echo answers; a target that reports neither cannot be used with the country and rotation rules, and the proxy shows exit_ip_unknown.
Pools
One monitor can hold several proxies. Each is a row on the monitor page with its own result, and the monitor carries the verdict for the pool.
{
"name": "Egress pool",
"proxy": {
"proxies": [
{ "name": "evomi-residential", "url": "http://USER:PASS_session-{session}@rp.evomi.com:1000" },
{ "name": "evomi-datacenter", "url": "http://USER:[email protected]:2000" },
{ "name": "evomi-socks", "url": "socks5://USER:[email protected]:1002" }
],
"samples": 5,
"minHealthyPct": 67
}
}When the proxies are one endpoint with different parameters, declare a template: every variant becomes a pool entry, and a variant with vars.country expects that exit country.
{
"name": "Evomi residential by country",
"proxy": {
"template": {
"url": "http://USER:PASS_country-{country}_session-{session}@rp.evomi.com:1000",
"variants": [
{ "vars": { "country": "US" } },
{ "vars": { "country": "DE" } },
{ "name": "uk", "vars": { "country": "GB" } }
]
},
"samples": 6, "minSuccess": 5, "minDistinctIps": 3
}
}{session} is replaced by a fresh random 8-character value on every request, so a provider that keys sticky sessions on it hands out a new exit each time. Without it, set a rotating endpoint or expect one IP.
On update, proxies replaces the list. An entry sent with its id (or name) and no url keeps the stored URL; an entry left out is removed. GET /monitors/:id/proxy returns the pool with the latest result per proxy and region:
{
"target": { "url": "https://upbutler.com/api/v1/echo/ip", "echo": true, "method": "GET", "expectedStatus": "200-299" },
"samples": 10, "minSuccess": 8, "expect": { "minDistinctIps": 5 }, "minHealthyPct": 50,
"entries": [{ "id": "us", "name": "us", "scheme": "http", "host": "rp.evomi.com", "port": 1000, "hasCredentials": true, "credentialHint": "fet…", "provider": "Evomi" }],
"regions": [
{ "region": "eu-central", "label": "Helsinki", "at": "2026-10-10T10:05:00.000Z", "outcome": "down", "healthy": 0, "total": 1,
"entries": [{ "id": "us", "name": "us", "proxy": "http://rp.evomi.com:1000", "outcome": "down",
"failure": "proxy_auth_failed", "message": "Proxy refused the tunnel: 407 Proxy Authentication Required",
"ok": 0, "samples": 10, "failures": { "proxy_auth_failed": 10 }, "exitIps": [], "connectMs": 41, "totalMs": 92 }] }
]
}Failure classes
| Class | Means | Usually |
|---|---|---|
proxy_unreachable | DNS, TCP or TLS to the proxy itself failed, or timed out | Proxy host or port wrong, provider gateway down, firewall |
proxy_auth_failed | 407, or the SOCKS5 user/password exchange was refused | Rotated password, IP not on the allow-list |
out_of_credits | The provider says traffic, balance or the plan is used up (402, or its documented message) | Top up, or raise the zone limit |
rate_limited | The provider throttles the account (429, or its documented code) | Too many concurrent sessions |
provider_error | Another documented provider refusal: no exit node for the country or session, bad parameter, KYC | Geo parameter, session settings |
tunnel_refused | The proxy answered and would not open the tunnel (403, SOCKS5 "not allowed") | Target on the provider's block list |
target_unreachable | The proxy could not reach the target (502 / 503 / 504, SOCKS5 host unreachable) | Target down, or the exit node is |
target_blocked | The target answered 403 / 429 or a captcha page | The exit IPs are flagged by the target |
bad_status / bad_body | The target answered, with another status or without the expected text | Your expectation, or the target |
tls_error | TLS to the target failed inside the tunnel | Interception, wrong certificate |
timeout | The tunnel or the response did not arrive within the check timeout | Slow exit nodes |
wrong_exit_country | A request left from another country than expected | Geo targeting not applied |
not_rotating | Fewer distinct exit IPs than required among the requests | Sticky session where a rotating one was meant |
exit_ip_is_proxy | The exit IP is the address of the proxy host | The gateway is not handing out other IPs |
low_success_rate | Fewer requests succeeded than minSuccess, for mixed reasons | Pool quality |
slow | The median request is slower than maxLatencyMs (degraded) | Congested pool |
refused | UpButler did not send the request: a private proxy host or target from a public region | Run it on a private probe |
The classes appear per proxy on the monitor page, in the alert, in the AI incident report and in the packet an agent responder receives (hosts and timings, credentials scrubbed).
Provider-specific answers
Providers refuse requests with their own codes. Where their documentation states the shape, UpButler recognises it and names the provider in the message; everything else falls back to the generic rules (407 = authentication, 402 = out of credits, 429 = rate limited, 502 / 503 / 504 = target unreachable).
| Provider | Recognised | Source |
|---|---|---|
| Bright Data | proxy auth failed, out of credits, provider error, rate limited, tunnel refused, target unreachable | docs.brightdata.com |
| Oxylabs | proxy auth failed, out of credits, tunnel refused, provider error, target unreachable, rate limited | developers.oxylabs.io |
| Decodo | proxy auth failed, out of credits, tunnel refused, provider error, target unreachable | help.decodo.com |
| IPRoyal | provider error, target unreachable | docs.iproyal.com |
| Evomi | provider error | docs.evomi.com |
| Webshare | Generic rules only: nothing on the wire is documented | help.webshare.io |
Bright Data is matched on its x-brd-err-code header, Oxylabs on X-Error-Description, Decodo on x-error-message; IPRoyal and Evomi on the few statuses their docs define, and only on their own hosts. SOCKS5 reply codes are not documented by any of them and are read per RFC 1928.
Never public
- A proxy monitor is on no status page unless you attach it to a component. Then visitors see that component's name and status and nothing else: no monitor name, no proxy host, no provider, no error text.
- That holds for every public surface: the page, its JSON,
status.agent.json, the page MCP server,llms.txt, RSS and Atom feeds, subscriber notifications. - An automatic incident for a proxy monitor is titled by its components, and AI-written public text that names the monitor, a pool entry, a proxy host or the provider is not published (the template text is used instead).
Regions, private probes and what is refused
Proxy monitors run from the main region, from other regions (a proxy that works from Helsinki and not from Ashburn is a finding), and from private probes inside your network.
From public regions, UpButler connects only to proxies on public addresses and only fetches public hostnames through them: a proxy is an egress, and it must not be a way to reach an internal address. A private IP literal, localhost, a single-label or .internal / .local name, and a public-looking name that resolves to a private address are refused (refused), at save time and again on every check. To monitor an internal proxy or reach an internal target through one, run the monitor on a private probe only.
In upbutler.yaml
monitors:
- id: evomi-residential
name: Evomi residential
kind: proxy
intervalSec: 300
proxy:
proxies:
- name: us
url: ${env:EVOMI_PROXY_US} # http://user:pass_country-US_session-{session}@rp.evomi.com:1000
expectCountry: US
- name: de
url: ${env:EVOMI_PROXY_DE}
expectCountry: DE
samples: 10
minSuccess: 8
minDistinctIps: 5
maxLatencyMs: 4000
# The same pool, checked from inside your network by a private probe:
- id: squid-internal
kind: proxy
probes: [prod-vpc]
proxy:
url: ${env:INTERNAL_SQUID_URL} # http://user:[email protected]:3128
target: http://billing.internal/health${env:NAME} is read from your environment by upbutler apply, never from a file. upbutler export writes each proxy by id without its URL; applying such a file keeps the stored URLs.
Limits and cost
- A proxy monitor counts as one monitor. Requests per check:
samples× proxies × regions, at most 20 samples per proxy, ten requests in flight at a time. Every request uses your proxy traffic. - The check timeout (default 30 seconds) covers the whole check. Requests that could not start in time count as
timeout: lowersamplesor raise the timeout for large pools. - Responses are read up to 256 KB; redirects are not followed (set
expectedStatusaccordingly). - The echo endpoint is rate-limited to 120 requests a minute per exit address.
| Plan | Proxies per proxy monitor |
|---|---|
| Free | 1 |
| Starter | 5 |
| Pro | 25 |
| Business | 100 |
Fields
The proxy object of POST /monitors and PATCH /monitors/:id:
| Field | Type | Description |
|---|---|---|
url | string | Write-only.min length 8 · max length 2,000 |
proxies | object[] | The pool: one row per proxy on the monitor page. On update the list replaces the stored one; an entry sent with its id (or name) and no url keeps the stored URLmax 100 items |
proxies[].id | string | Id of an existing entry, to keep its stored URL on updatemax length 80 |
proxies[].name | string | max length 80 |
proxies[].url | string | Proxy URL with credentials: http://user:pass@host:port, https://… or socks5://…. "{session}" anywhere in it is replaced by a fresh random value per request (rotating pools). Write-only: stored encrypted, never returnedmin length 8 · max length 2,000 |
proxies[].expectCountry | string | This entry's exit country (overrides expectCountry)pattern ^[A-Za-z]{2}$ |
template | object | A pool from one endpoint and parameter sets (countries, products). Entries from a template come after those in proxies |
template.urlrequired | string | One endpoint with placeholders, e.g. "http://USER:PASS_country-{country}_session-{session}@rp.evomi.com:1000"min length 8 · max length 2,000 |
template.variantsrequired | object[] | One pool entry per variant: every {name} in the URL is replaced by vars.name. A variant with vars.country expects that exit country unless expectCountry says otherwisemax 100 items |
template.variants[].name | string | max length 80 |
template.variants[].varsrequired | map<string, string> | |
template.variants[].expectCountry | string | pattern ^[A-Za-z]{2}$ |
target | string (url) | null | URL fetched through the proxy. Default (or null): UpButler's IP echo, which reports the exit IP and country. A custom target must be a public hostname unless the monitor runs on a private probemax length 2,000 · nullable |
method | string | GETHEAD |
expectedStatus | string | e.g. "200-299" (default)max length 100 |
keyword | string | null | The response body must contain this text (null clears)max length 500 · nullable |
samples | integer | Requests per proxy and check (default 1). Rotation and success-rate rules need severalmin 1 · max 20 |
minSuccess | integer | How many of the samples must succeed, e.g. 8 of 10 (default: all up to 2 samples, else 80%)min 1 · max 20 |
expectCountry | string | null | Exit country every proxy must show (ISO code; null clears)pattern ^[A-Za-z]{2}$ · nullable |
exitIpDiffers | boolean | The exit IP must not be the address of the proxy host itself |
minDistinctIps | integer | null | Rotating pools: at least this many different exit IPs among the samples (null clears)min 1 · max 20 · nullable |
maxLatencyMs | integer | null | Degraded when the median request through the proxy is slower (null clears)min 50 · max 120,000 · nullable |
minHealthyPct | integer | Pool verdict: down when fewer than this share of the proxies is healthy (default 50); degraded while any proxy is failingmin 1 · max 100 |