Contents

Stats & observatory

Traffic counters, online tracking, and outbound health probing.

A proxy you can’t measure is a proxy you can’t debug, bill, or load-balance. Xray solves this with two small apps: stats (byte counters and online-IP maps, queried over the API) and observatory (a background prober that measures each outbound’s real latency and feeds it to balancers).

Counters are wrapped around the data path, not polled

app/stats/stats.go app/stats/counter.go app/dispatcher/default.go app/proxyman/outbound/handler.go

A counter is just an atomic.Int64 spliced into the write path — there is no sampling loop and no metrics thread. When the dispatcher builds the uplink/downlink pipes for a connection (getLink in app/dispatcher/default.go), it wraps each pipe writer in a SizeStatWriter that does Counter.Add(mb.Len()) before passing the buffer on. Inbound and outbound handlers do the same at the connection level with stat.CounterConnection. If a byte moves, it is counted at the moment it moves; if stats are disabled, the wrapper is simply never installed and the cost is zero.

Three families of counters exist, keyed by a >>>-delimited name in one flat map inside stats.Manager:

Name patternAttached whereGated by policy key
user>>>{email}>>>traffic>>>uplinkdispatcher pipe (SizeStatWriter)levels.{n}.statsUserUplink / statsUserDownlink
inbound>>>{tag}>>>traffic>>>uplinkinbound worker (CounterConnection)system.statsInboundUplink / ...Downlink
outbound>>>{tag}>>>traffic>>>uplinkoutbound handler (CounterConnection)system.statsOutboundUplink / ...Downlink

Enabling all three is a policy decision, not a stats one — the "stats": {} object only instantiates the manager:

{
	"stats": {},
	"policy": {
		"levels": {
			"0": { "statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true }
		},
		"system": { "statsInboundUplink": true, "statsOutboundUplink": true }
	}
}

Online tracking is reference counting, not a heartbeat

app/stats/online_map.go

Xray knows a user is online because their connection context hasn’t died yet — there is no ping or timeout scan. With statsUserOnline enabled, the dispatcher registers an OnlineMap named user>>>{email}>>>online, calls AddIP(source) when a connection dispatches, and schedules RemoveIP with context.AfterFunc for when that connection’s context ends. Each IP holds a refCount: five parallel connections from one phone are one online IP, and the IP disappears the moment the last one closes. Connections from 127.0.0.1 and [::1] are skipped entirely, so API tooling running on the box never inflates the count.

The observatory probes through the pipeline it measures

app/observatory/observer.go transport/internet/tagged/taggedimpl/impl.go

A probe is a real proxied HTTPS request, not a TCP ping. Every cycle the Observer selects outbounds whose tags prefix-match subjectSelector, then issues GET https://www.google.com/generate_204 (or your probeURL) through each one. The trick is the dialer: tagged.Dialer stamps the context with SetForcedOutboundTagToContext and calls dispatcher.Dispatch, so the probe traverses the same routing and outbound handler as user traffic — a result of “alive, 213 ms” means the full tunnel works end to end, TLS handshake included.

Probes are dispatched with a forced outbound tag; balancers read the cached ObservationResult, never probe themselves

A successful probe stores Delay in milliseconds; a failed one stores Alive: false and Delay: 99999999. leastPing already skips non-alive statuses, but the sentinel is a second lock: it exactly equals the strategy’s starting comparison value (leastPing := int64(99999999) with a strict <), so a sentinel delay can never win a comparison anywhere delays are ranked.

Balancers read the cache, they never wait on a probe

app/router/strategy_leastping.go

Picking an outbound is a map scan over the last observation, adding zero latency to your connection. LeastPingStrategy.PickOutbound calls GetObservation, which just returns the status slice the background loop last wrote — then picks the alive outbound with the smallest Delay among the balancer’s candidates:

{
	"balancers": [
		{ "tag": "auto", "selector": ["proxy-"], "strategy": { "type": "leastPing" } }
	],
	"observatory": { "subjectSelector": ["proxy-"], "probeInterval": "30s" }
}

If you configure leastPing without an observatory, the strategy logs observer is nil and returns an empty tag. What happens next depends on the balancer: with a fallbackTag set it routes there; without one, Balancer.PickOutbound errors with “balancing strategy returns empty tag” and the connection falls through to the default (first) outbound — traffic keeps flowing, just not through the server you meant.

burstObservatory trades freshness for statistics

app/observatory/burst/healthping.go app/observatory/burst/healthping_result.go

One ping is an anecdote; burstObservatory keeps a ring buffer of them. Instead of a single rolling probe, it fires sampling checks per outbound spread at random offsets across each interval × sampling window, stores RTTs in a fixed-size HealthPingRTTS ring, and reports average, max, min, and standard deviation. Results older than twice the sampling period expire. An outbound is reported alive as long as at least one sample in the window succeeded (All != Fail), and Delay becomes the window average — so one latency spike no longer flips your balancer to another server. A quirk worth knowing: with a single valid sample, deviation is faked as average / 2, purely so freshly-added outbounds don’t beat well-measured ones in deviation-aware strategies like leastLoad.