netquality

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 11, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

README

netquality CI Go Reference

Measure the capacity, latency and responsiveness of a network path by running the IETF Responsiveness under Working Conditions test (draft-ietf-ippm-responsiveness-09) against Apple's, Cloudflare's, or your own server. Ships as three things: a Go library, the nq client, and nqserver, a reference server you can host yourself.

  • Windows, macOS and Linux on amd64 and arm64, supported as measurement targets rather than just build targets: where a platform's defaults would distort a number, the library works around them.
  • Standard library only, no CGO, so it adds nothing to your build. Requires Go 1.26+ (older lines no longer receive TLS/HTTP security fixes).
  • Every run is bounded by time and connection count before it starts, and by bytes too when you say the link is metered. Every number in the result says how it was obtained.
  • Sends nothing over the network except the test itself.
$ nq --target apple
Target     mensura.cdn-apple.com (HTTP/2.0) 17.253.24.71
Idle       102.2ms median, 131.8ms p80, jitter 32.5ms (5 probes)
Download      178.3 Mbps    365 RPM  loaded 185.2ms median, 512.6ms p99  [8 flows, high/high confidence]
Upload        125.9 Mbps    311 RPM  (>= 121.4 Mbps, <= 330 RPM over 4s)  loaded 160.7ms median, 471.0ms p99  [8 flows, medium/low confidence, TRUNCATED: duration_cap]
Cost       370.5 MB moved in 21.0s

What it measures

  1. Discovery. Fetches the server's JSON config (/.well-known/nq or a vendor path) to learn the small-download, large-download and upload URLs.
  2. Idle latency. IdleProbes sequential GETs of the small resource (1 byte in the draft; up to 10 accepted for Cloudflare compatibility), each on a fresh connection, so a sample includes DNS + TCP + TLS + HTTP. Per-stage medians are reported via net/http/httptrace.
  3. Download under load, then upload under load (sequentially, so the two loaded-latency figures are distinct). Each phase:
    • opens one HTTP/2 connection to the large resource and adds one more every interval (1 s) up to MaxFlows;
    • meanwhile fires foreign probes (fresh connections) and self probes (multiplexed on a random load connection), interleaved, at up to MaxProbesPerSecond and never more than 5 % of measured capacity;
    • declares throughput stable when the standard deviation of the last four moving averages is under 5 % of the current one, then does the same for responsiveness and stops.
  4. Responsiveness (RPM). "Round trips per minute", 60000 / RTT_ms. Following the draft: foreign = 60000 / mean(TM(tcp), TM(tls per RTT), TM(http)), self = 60000 / TM(http_on_loaded_connection), RPM = (foreign+self)/2, where TM is the single-sided trimmed mean at the 95th percentile over the last four intervals. Roughly: < 300 RPM poor, > 1000 good, > 6000 excellent.
  5. Jitter. Mean absolute deviation of the samples from their mean, reported for idle and loaded sets. Percentiles (p80/p90/p95/p99) appear only when there are enough samples for them to differ from the maximum (5/10/20/100 under nearest rank), so the default five idle probes yield p80, never a fake p95.
  6. Cost. Bytes moved and wall time per phase.

Library

import "github.com/korya/netquality"

res, err := netquality.Run(ctx, netquality.Cloudflare, netquality.Options{
    MaxDuration: 10 * time.Second, // per direction; the budget
    MaxBytes:    100 << 20,        // optional: only on metered links
    MaxFlows:    8,
    IdleTimeout: 5 * time.Second, // budget for all idle probes together
})
if err != nil { /* discovery failed, or ctx cancelled (res is then partial) */ }
fmt.Println(res.Download.RPM, res.Download.ThroughputBPS, res.Download.Truncated)

Result.Target records the server IPs the flows reached (resolved_ips) and the local source addresses they went out on (local_ips), so a stored result can be tied to the interface or network it was measured on. Both are present on cancelled partial results too, and both stay in the result if you share it.

Targets: netquality.Apple, netquality.Cloudflare, netquality.WellKnown("host:port"), or netquality.Target{ConfigURL: "..."}.

Options.Events takes a func(Event) sink for progress bars. Proxies and TLS settings come through Options.HTTPClient (its *http.Transport is cloned per flow so each flow owns a connection). Options.Logger accepts a *slog.Logger.

Result marshals to JSON with stable snake_case names; the CLI's --json output is exactly that struct. schema_version (currently 1) is its first field: it changes only when a field is renamed, removed, retyped, or changes meaning, never for additions, so stored documents stay interpretable. Directions that did not run are omitted, not zero. Each DirectionResult carries truncated, reason (bytes_cap | duration_cap | cancelled | flow_error) and the draft's throughput_confidence / responsiveness_confidence (low | medium | high). Whenever four consecutive intervals agreed, a direction also carries throughput_lower_bound_bps, with its lower_bound_window and rpm_upper_bound (LOAD-13). That figure is deliberately conservative: it holds even when the estimate did not converge.

CLI

nq                                   # Cloudflare, both directions
nq --target apple
nq --well-known nq.example.com:8443 [--insecure]
nq --config-url https://host/path/config
nq --download-only | --upload-only
nq --max-duration 8s --max-flows 8          # time is the budget
nq --idle-timeout 5s                       # cap the whole idle measurement phase
nq --max-bytes 100MB                         # metered link: add a byte cap
nq --json                            # Result as one JSON document on stdout
nq --events                          # JSON-lines progress on stderr
nq version

Exit codes: 0 ok, 1 the test failed or was cancelled, 2 bad usage.

Build with version info:

go build -ldflags "-X github.com/korya/netquality/internal/buildinfo.Version=v0.1.0 \
  -X github.com/korya/netquality/internal/buildinfo.Commit=$(git rev-parse HEAD)" ./cmd/nq

Self-hosting nqserver

go run ./cmd/nqserver --self-signed --listen :8443
nq --well-known localhost:8443 --insecure

For real deployments pass --cert/--key; --base-url sets the advertised URL prefix when behind a load balancer, --test-endpoint advertises a specific host. The server needs HTTP/2 end to end and must not compress or redirect.

Access control. With a real certificate the server refuses to start anonymously; give it a token and give the client the same one:

NQSERVER_AUTH_TOKEN=s3cret nqserver --cert c.pem --key k.pem
NQ_AUTH_TOKEN=s3cret nq --well-known nq.example.com

(--auth-token works on both; the environment keeps the secret out of ps.) Every endpoint, config included, answers 401 without the token. Library callers set Options.Header. --allow-anonymous opts out explicitly; --self-signed implies it for local development.

Load limits protect egress without biasing measurements. They gate whether a request may start, and never slow down one that has already begun:

Flag Default Effect
--client-bytes / --client-window 8 GiB / 10 min (unlimited with --self-signed) token-bucket credit per client IP or signed subject; refusal gets 429 + Retry-After
--client-concurrency 32 active large/download and upload requests per budget identity; nonpositive selects 32; disabled when the byte budget is disabled
--upload-size 16 GiB bytes accepted by one upload
--large-size 8 GiB bytes served by one download
--max-connections 256 extra connections wait in the accept queue
--idle-timeout 2 min closes a connection with no request in flight; HTTP/2 peers are pinged after 30 s of silence; transfers are never cut

The server checks credit and a free slot atomically before a transfer starts, then charges the actual payload bytes and releases the slot when the handler returns, including after cancellation or an I/O error; a panic charges the request cap. Config and small probe requests never take a slot. --client-bytes -1 disables byte budgeting and admission slots together, and an explicit --client-concurrency then warns that it is ignored, --self-signed included. To keep the concurrency bound without practical byte refusals, set --client-bytes high rather than negative.

It is not a strict quota. With concurrency C and the larger request cap R, admitted payload can overshoot available credit by C × R: 512 GiB on the defaults (32 × 16 GiB), on top of the 8 GiB bucket and whatever it refills. HTTP and TLS overhead, transport buffering and body cleanup fall outside the accounting entirely. Retry-After is a hint, not a reservation: one second for a concurrency refusal, byte debt rounded up to seconds and saturated when the debt is very large.

Stalled requests can deny a shared identity indefinitely. Slots have no expiry, so an upload that sends no body, a download whose receiver stops reading, or either stalling mid-transfer holds its slot until the handler exits, blocking large transfers for everyone on that identity across any number of refill windows. Idle timeouts do not reclaim active requests, and HTTP/2 pings cannot distinguish a stalled body from a peer that still answers pings.

Size --client-concurrency for one identity's simultaneous load flows plus teardown overlap, and --max-connections for every client's load connections, fresh probes and teardown. HTTP/2 multiplexes, so the two count different things: one identity can exhaust its 32 slots long before the server reaches 256 connections, and raising the global limit adds no per-client slots. The default leaves headroom for a client's 16 flows; raise it for more flows or more clients behind one address, and expect 429 with flow_error if it is too low. Library callers set server.Options.MaxClientConcurrency. Behind a load balancer every client keys to the balancer's address, because the server deliberately does not trust X-Forwarded-For. Only a signed sub separates devices there; a shared bearer token leaves them all on one IP-keyed budget.

Signed URLs: no secret on clients. Your backend serves the config document with test URLs it has signed; nqserver verifies them and the laptop never holds a reusable credential:

nqserver sign --new-key                       # once: a 32-byte key, keep it on the backend and the server
NQSERVER_SIGNING_KEY=<key> nqserver --cert c.pem --key k.pem
nqserver sign --key <key> --ttl 10m --sub laptop-7 https://nq.example.com/nq/small \
    https://nq.example.com/nq/large https://nq.example.com/nq/upload

Put the three signed URLs in a config document served by your backend and point the client at it (Target{ConfigURL: "https://backend/nq-config"}). The client needs no flags. The signature covers only the path, exp and sub: sig = base64url(HMAC-SHA256(key, path + "\n" + exp + "\n" + sub)), so any language can issue one, and unsigned parameters are deliberately unprotected (never let a server trust them). sub keys the per-client budget, so ten laptops behind one NAT get ten budgets. Repeat --signing-key to rotate; Go backends can call server.SignURL. Validity is exp plus 30 s of leeway and at most 24 h, so keep the two clocks in sync: a server running behind the issuer refuses everything as "issued too far ahead". The encoding rules that bite in practice, including which form of the path to sign and why sub must be percent-encoded, are in SRV-10.

mTLS works today without a flag: wrap server.Handler in your own http.Server with TLSConfig.ClientAuth = tls.RequireAndVerifyClientCert and ClientCAs, and give the client its certificate via Options.HTTPClient.Transport.TLSClientConfig.Certificates. A --client-ca flag is tracked in issue #10.

Proxies

Corporate laptops often sit behind proxies, in which case the test measures the laptop→proxy leg, not the path to the server. The result says so rather than reporting plausible-but-wrong numbers:

  • Explicit proxy (HTTPS_PROXY, PAC, or Transport.Proxy on the client you pass in): target.proxy.explicit=true with the proxy url (credentials stripped). HTTP/2 still works through CONNECT tunnels. test_endpoint cannot be honoured, and a warning says so.
  • TLS interception (Zscaler, Netskope, and similar): the certificate chain verifies against the corporate root, but the leaf carries no Certificate Transparency SCTs, which every publicly trusted certificate has had since 2018. target.proxy.tls_interception=true with the issuer and a reason. A self-hosted nqserver behind a private CA triggers the same flag, since the trust situation is identical; the wording says "proxy or private CA". --insecure skips verification and therefore never triggers it.

Both cases add a warning. Confidence scores are unaffected: the algorithm converged on a real measurement, just of a shorter path. Not detected: proxies that re-issue publicly trusted certificates (not possible without a CA compromise) and transparent TCP-level proxies that pass TLS through untouched (those are not altering the measurement).

Safety limits

Limit Default Effect
ConfigTimeout 10 s bounds discovery; failure returns no result
IdleTimeout 10 s bounds all idle probes together; keeps completed samples, warns, then proceeds to load
MaxDuration 12 s per direction the budget: phase ends; if not yet stable → truncated, reason=duration_cap. Cost ≤ rate × 12 s
MaxBytes none (opt-in) set on metered links; phase ends → reason=bytes_cap
MaxFlows 16 never more concurrent load connections
Small response body 1-10 bytes reject empty/oversized responses; read at most 11 bytes to detect overflow; discard invalid samples and warn
ctx cancellation n/a all flows stop within ~200 ms; partial result, cancelled=true

The combined phase budget is ConfigTimeout + IdleTimeout + N × MaxDuration, where N is the selected direction count; omit IdleTimeout when idle is skipped. Defaults total 44 s for both directions, 32 s for one, or 34 s for both with idle skipped. Zero or negative duration options select defaults, so they cannot disable the bounds, and an earlier caller deadline stops the run while keeping what was already measured. IdleTimeout is a flat cap that does not grow with IdleProbes, so a large sample set or a healthy slow path may need a larger one to reach the requested percentiles; the warning it produces means the budget ran out, not that the connection is faulty. The server flag of the same name is unrelated: it bounds quiet connections between requests.

Return time also includes local orchestration and prompt teardown. Supplied transports, dialers, body closers, event sinks, and log handlers must honor cancellation where applicable and return promptly; the library cannot forcibly stop caller code. Runner goroutines join and owned sockets close before return. A custom TLS dialer still executing at teardown has its connection closed when it hands it over. There are no retries or telemetry.

MaxBytes is not an exact wire-byte limit. Probe cost is estimated (see Deviations) and charged even for failed loaded attempts, idle and discovery sit outside the per-direction totals, and headers, TLS, read-ahead, in-flight work and cancellation can all exceed the accounted budget.

A server whose small response leaves the 1-10 byte range stops being usable for latency probing. Load still collects capacity within the configured budgets even when every probe fails, with loaded latency absent and RPM zero, so MaxBytes and MaxDuration are what bound that cost. Invalid-size warnings are capped at one per phase and probe kind. Dated compatibility checks record the observed Apple and Cloudflare response sizes and how to repeat the small GETs.

Deviations from the draft

Item Draft Here Why
Interval (ID) 5 s 1 s 4 intervals must complete before stability can be declared; with the 12 s per-direction budget a 5 s interval could never stabilise. Earlier drafts and shipping tools use 1 s. Configurable via Stability.Interval.
Time budget "implementations may" limit mandatory discovery, idle, and per-direction time caps; MaxBytes opt-in Runs on other people's machines and networks; a time bound makes cost proportional to the link instead of unbounded.
Byte cap default (handoff spec: 250 MB) none A fixed byte cap starves fast links of the intervals a confident RPM needs (≈ 8 × rate); the caller knows which networks are metered, the library cannot.
Flow error abort the test abort the phase, report reason=flow_error, keep other results Partial data with a flag beats none.
Self probes on HTTP/1.1 use TCP RTT estimate omitted; RPM from foreign probes only, warning recorded TCP_INFO is not portable in pure Go.
Server admission successful load endpoints return 200 per-client byte credit and concurrent-request slots may refuse new load requests with 429 Bounds admitted work without throttling transfers already running; probes are exempt.
Flow addition one per interval doubling each interval while a step gains ≥ 10 % goodput, up to MaxFlows Reaches saturation in ≤ 5 intervals instead of 16, so a 10 Gbps or high-RTT link still settles inside the 12 s budget; a slow link stops after one exploratory flow.
Responsiveness tracking after goodput stability from the end of the ramp; stability judged on the windowed values, not on averages of them Removes 3-4 s of latency from every run; the phase still ends only with both series stable.
Upload byte accounting not specified intervals inflated by the HTTP/2 send window of new flows are excluded Bytes are counted when the transport takes them; on a 20 Mbps link the 4 MiB credit otherwise reports 53 Mbps with high confidence.
Capacity change not specified a > 25 % goodput drop restarts stability tracking The draft averages across the change.
Responsiveness window last MAD intervals every sample since throughput became stable (loaded_window) Foreign probes are sparse (a TLS handshake each); a fixed 4-tick window could hold self samples and no foreign ones, which read as "no fresh connection ever succeeded". Stability is still judged on the draft's window.
Probe byte accounting not specified foreign 5000 B, self 1000 B (draft's estimates) Counted against MaxBytes and the 5 % capacity rule.
Small response size 1 byte complete nonempty bodies up to 10 bytes accepted; fixed ceiling, no override Preserve Cloudflare's advertised ten-byte probe; reject empty and larger bodies before they become latency samples.
Config version must be 1 1 or "1" accepted Lenient on the wire, strict on everything else (duplicates, hosts, scheme).
Config field names *_download_url, upload_url also accepts Apple/Cloudflare *_https_* names, preferring them Interop with deployed servers.
Cloudflare target mach hardcodes h3.speed.cloudflare.com URLs uses aim.cloudflare.com/responsiveness/api/v1/config, which returns the same URLs Keeps discovery uniform.

Other constants: IdleProbes=5 (enough for a median, cheap), ConfigTimeout=10s, IdleTimeout=10s, in-flight probe cap 64 (bounds goroutines on high-RTT links), TLS handshake normalised to 1 RTT for TLS 1.3 and 2 for TLS 1.2.

Testing

go test ./...            # unit + end-to-end against an in-process nqserver
NQ_LIVE=1 go test -run TestLive -v .   # hits Apple and Cloudflare

docs/test-matrix.md maps every feature and use case to the test that exercises it; TestMatrix fails if a row names a missing test or a test is missing from the matrix. Behaviours only observable on real networks (stability under bufferbloat, probe throttling on slow links) run in the nightly Live workflow, which never blocks merges.

Releasing

  1. Move the [Unreleased] items in CHANGELOG.md under a new ## [X.Y.Z] - date heading.
  2. Commit, then git tag -a vX.Y.Z -m "netquality vX.Y.Z" && git push origin master vX.Y.Z.

The Release workflow runs the tests, builds nq and nqserver for all six platforms, and publishes a GitHub release whose notes are that changelog section. It fails if the section is missing.

Licence

Apache-2.0. Written from the draft and Apple's MIT-licensed SERVER_SPEC.md; no code from GPL implementations (e.g. goresponsiveness) is included.

Documentation

Overview

Package netquality measures download/upload capacity, idle and loaded latency, jitter and responsiveness (RPM) of a network path by running the IETF "Responsiveness under Working Conditions" test (draft-ietf-ippm-responsiveness) against a compatible server.

The library depends only on the standard library, emits no traffic besides the test itself, and bounds every run by duration, bytes and flow count before it starts. See Options for the limits and Result for how each number was obtained.

Typical use:

res, err := netquality.Run(ctx, netquality.Cloudflare, netquality.Options{})
if err != nil { ... }
fmt.Printf("%.0f RPM, %.1f Mbps down\n", res.Download.RPM, res.Download.ThroughputBPS/1e6)
Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/korya/netquality"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
	defer cancel()

	res, err := netquality.Run(ctx, netquality.WellKnown("nq.example.com"), netquality.Options{
		MaxDuration: 10 * time.Second,
		MaxBytes:    100 << 20,
		MaxFlows:    8,
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("idle %v, down %.1f Mbps / %.0f RPM (truncated=%v), up %.1f Mbps / %.0f RPM\n",
		res.Idle.Median, res.Download.ThroughputBPS/1e6, res.Download.RPM, res.Download.Truncated,
		res.Upload.ThroughputBPS/1e6, res.Upload.RPM)
}

Index

Examples

Constants

View Source
const (
	DefaultMaxDuration = 12 * time.Second
	// DefaultMaxBytes is 0: no byte cap by default. Time is the budget —
	// cost is at most rate × MaxDuration per direction — so a confident
	// result is reachable at any link speed. Callers on metered links set
	// MaxBytes explicitly.
	DefaultMaxBytes = 0
	DefaultMaxFlows = 16
	// DefaultUploadSendBuffer is the per-flow credit assumed for uploads: Go's
	// HTTP/2 stream window, which the transport fills before bytes reach the
	// wire (StabilityParams.SendBufferBytes).
	DefaultUploadSendBuffer = 4 << 20
	DefaultIdleProbes       = 5
	// DefaultConfigTimeout bounds config discovery, which happens before the
	// per-direction budgets apply.
	DefaultConfigTimeout = 10 * time.Second
	// DefaultIdleTimeout bounds the whole idle measurement phase, regardless
	// of how many probes were requested.
	DefaultIdleTimeout = 10 * time.Second
)

Default safety limits and probe counts.

View Source
const ResultSchemaVersion = 1

ResultSchemaVersion identifies the JSON shape of Result. It is bumped when a field is renamed, removed, retyped, or changes meaning; adding fields never bumps it. Each bump is noted in the CHANGELOG.

Variables

View Source
var (
	// Apple is Apple's public responsiveness server (used by macOS `networkQuality`).
	Apple = Target{ConfigURL: "https://mensura.cdn-apple.com/api/v1/gm/config"}
	// Cloudflare is Cloudflare's public responsiveness server. Its `mach` CLI
	// hardcodes the h3.speed.cloudflare.com URLs; this config document returns
	// the same URLs.
	Cloudflare = Target{ConfigURL: "https://aim.cloudflare.com/responsiveness/api/v1/config"}
)

Well-known public targets.

View Source
var ErrInvalidConfig = errors.New("netquality: invalid server configuration")

ErrInvalidConfig is returned (wrapped) when a configuration document must be ignored per draft Section 8.1.

Functions

This section is empty.

Types

type Confidence

type Confidence string

Confidence is the draft's Section 5.4.1 confidence score.

const (
	// ConfidenceLow: fewer than MovingAverageDistance intervals ran; the
	// moving average is partial.
	ConfidenceLow Confidence = "low"
	// ConfidenceMedium: at least MovingAverageDistance intervals ran but
	// stability was not reached.
	ConfidenceMedium Confidence = "medium"
	// ConfidenceHigh: stability was reached.
	ConfidenceHigh Confidence = "high"
)

type DirectionResult

type DirectionResult struct {
	Direction string `json:"direction"`
	// ThroughputBPS is the moving-average goodput (bits/s) over the last MAD
	// intervals at the end of the phase.
	ThroughputBPS float64 `json:"throughput_bps"`
	// PeakThroughputBPS is the highest single-interval goodput observed.
	PeakThroughputBPS float64 `json:"peak_throughput_bps"`
	// MeanThroughputBPS is Bytes*8/Duration over the whole phase, ramp-up
	// included. It is the only throughput figure when no interval completed.
	MeanThroughputBPS float64 `json:"mean_throughput_bps"`
	// Bytes moved by this phase (payload of load flows plus probe bodies).
	Bytes int64 `json:"bytes"`
	// Duration is the wall time of the phase.
	Duration time.Duration `json:"duration_ns"`
	// Flows is the number of load-generating connections in use at the end.
	Flows int `json:"flows"`
	// Intervals is the number of stability intervals that completed.
	Intervals int `json:"intervals"`
	// ThroughputStable / ResponsivenessStable report whether the draft's stability
	// criterion was met for each series.
	ThroughputStable     bool `json:"throughput_stable"`
	ResponsivenessStable bool `json:"responsiveness_stable"`
	// ThroughputConfidence / ResponsivenessConfidence per draft Section 5.4.1.
	ThroughputConfidence     Confidence `json:"throughput_confidence"`
	ResponsivenessConfidence Confidence `json:"responsiveness_confidence"`
	// Truncated is true when a limit ended the phase before both series were
	// stable; Reason says which limit.
	Truncated bool             `json:"truncated"`
	Reason    TruncationReason `json:"reason,omitempty"`
	// Loaded latency statistics gathered while the link was under load, over
	// LoadedWindow: every interval since throughput became stable, or the
	// last MovingAverageDistance intervals when it never did (LOAD-5). A
	// probe series with samples in the phase but none in the window is
	// omitted and explained in Warnings.
	Loaded       LoadedLatency   `json:"loaded"`
	LoadedWindow *IntervalWindow `json:"loaded_window,omitempty"`
	// ThroughputLowerBoundBPS is a conservative figure that holds even when
	// the estimate has not converged: the lowest goodput of the latest
	// sustained window of measured intervals (LOAD-13). Absent when no such
	// window formed (a run cut short). LowerBoundWindow says which window.
	ThroughputLowerBoundBPS float64         `json:"throughput_lower_bound_bps,omitempty"`
	LowerBoundWindow        *IntervalWindow `json:"lower_bound_window,omitempty"`
	// RPMUpperBound is the responsiveness over the lower-bound window. Queues
	// may not have been full there, so the loaded RPM is at most this.
	RPMUpperBound float64 `json:"rpm_upper_bound,omitempty"`
	// RPM is the draft's Responsiveness score: the mean of ForeignRPM and SelfRPM
	// (or ForeignRPM alone when self probes were unavailable).
	RPM float64 `json:"rpm"`
	// ForeignRPM = 60000 / mean(TM(tcp_f), TM(tls_f), TM(http_f)).
	ForeignRPM float64 `json:"foreign_rpm"`
	// SelfRPM = 60000 / TM(http_l). 0 when no self probes were possible.
	SelfRPM float64 `json:"self_rpm"`
	// HTTPVersion negotiated by this phase's flows.
	HTTPVersion string `json:"http_version,omitempty"`
	// FlowErrors counts load flows that ended with an error.
	FlowErrors int `json:"flow_errors"`
}

DirectionResult is the outcome of one load phase.

type Directions

type Directions int

Directions selects which load phases run. Phases always run sequentially.

const (
	// Both runs download then upload (default).
	Both Directions = iota
	// Download runs only the download phase.
	Download
	// Upload runs only the upload phase.
	Upload
)

func (Directions) MarshalJSON

func (d Directions) MarshalJSON() ([]byte, error)

MarshalJSON keeps Directions readable in JSON output.

func (Directions) String

func (d Directions) String() string

type Event

type Event struct {
	Time      time.Time `json:"time"`
	Kind      EventKind `json:"kind"`
	Phase     string    `json:"phase"` // "discover", "idle", "download", "upload", "done"
	Direction string    `json:"direction,omitempty"`
	Message   string    `json:"message,omitempty"`
	// Interval fields.
	Interval      int     `json:"interval,omitempty"`
	Flows         int     `json:"flows,omitempty"`
	ThroughputBPS float64 `json:"throughput_bps,omitempty"`
	Bytes         int64   `json:"bytes,omitempty"`
	RPM           float64 `json:"rpm,omitempty"`
	// Hold marks an interval no flow was added into (LOAD-13).
	Hold bool `json:"hold,omitempty"`
	// Probe fields.
	ProbeKind string        `json:"probe_kind,omitempty"` // "idle", "foreign", "self"
	Latency   time.Duration `json:"latency_ns,omitempty"`
}

Event is a progress notification delivered to Options.Events. Sinks are called synchronously from the flow and probe goroutines, so calls can and do overlap: a sink must be safe for concurrent use and must return promptly (RES-8).

type EventKind

type EventKind string

EventKind classifies an Event.

const (
	// EventPhase marks a phase boundary; Phase names it, Message may add detail.
	EventPhase EventKind = "phase"
	// EventInterval reports the moving-average throughput after each interval.
	EventInterval EventKind = "interval"
	// EventProbe reports one latency sample.
	EventProbe EventKind = "probe"
	// EventFlow reports a flow being added (Flows is the new count).
	EventFlow EventKind = "flow"
	// EventWarning carries a warning as it is discovered.
	EventWarning EventKind = "warning"
)

type IntervalWindow added in v0.4.0

type IntervalWindow struct {
	Start     time.Duration `json:"start_ns"`
	Duration  time.Duration `json:"duration_ns"`
	Intervals int           `json:"intervals"`
}

IntervalWindow locates the intervals a figure was computed over, as an offset from the phase start and a length, in nominal intervals.

type LatencyStats

type LatencyStats struct {
	Samples int           `json:"samples"`
	Min     time.Duration `json:"min_ns"`
	Median  time.Duration `json:"median_ns"`
	Mean    time.Duration `json:"mean_ns"`
	P80     time.Duration `json:"p80_ns,omitempty"`
	P90     time.Duration `json:"p90_ns,omitempty"`
	P95     time.Duration `json:"p95_ns,omitempty"`
	P99     time.Duration `json:"p99_ns,omitempty"`
	Max     time.Duration `json:"max_ns"`
	Jitter  time.Duration `json:"jitter_ns"`
	Stages  *StageMedians `json:"stages,omitempty"`
}

LatencyStats summarises a set of latency samples.

Percentiles use the nearest-rank method, which returns the maximum for any percentile above 100·(n-1)/n. A percentile field is therefore present only when the sample count makes it a real order statistic distinct from the maximum: P80 from 5 samples, P90 from 10, P95 from 20, P99 from 100. A field never holds a lower percentile than its name says; absent means "not enough samples", never zero.

Jitter is the mean absolute deviation of the samples from their mean. Stages holds per-stage medians (dns, connect, tls, ttfb) when the samples carry stage timings (foreign probes and idle probes do; self probes do not).

func (LatencyStats) HighestPercentile added in v1.0.0

func (s LatencyStats) HighestPercentile() (float64, time.Duration)

HighestPercentile returns the largest percentile present and its value, or (0, 0) when the set is too small for any.

type LoadedLatency

type LoadedLatency struct {
	// Foreign: fresh-connection probes (TCP+TLS+HTTP, like idle probes).
	Foreign *LatencyStats `json:"foreign,omitempty"`
	// Self: probes multiplexed on load-generating HTTP/2 connections.
	Self *LatencyStats `json:"self,omitempty"`
	// Combined: all probes, with foreign probes counted by their HTTP time
	// (http_f) so both kinds measure "request to full response".
	Combined *LatencyStats `json:"combined,omitempty"`
}

LoadedLatency groups latency samples taken under load.

type Options

type Options struct {
	// MaxDuration bounds each direction's load phase (default 12s). A phase that
	// has not stabilised when the bound hits is reported as truncated.
	MaxDuration time.Duration
	// MaxBytes, if > 0, bounds the bytes moved by each direction's load
	// phase, probes included; hitting it truncates the phase with
	// reason=bytes_cap. 0 (the default) means no byte cap: MaxDuration
	// bounds each load phase. Set it on metered links.
	// Probe attempts use fixed cost estimates; idle/discovery are excluded.
	// Transport buffering and cancellation can exceed this accounting budget.
	MaxBytes int64
	// MaxFlows caps the number of concurrent load-generating connections
	// (default 16, draft MNP).
	MaxFlows int
	// Directions selects the phases to run (default Both).
	Directions Directions
	// IdleProbes is the number of fresh-connection probes for idle latency
	// (default 5). 0 uses the default; negative skips idle measurement.
	IdleProbes int
	// IdleTimeout bounds the whole idle measurement phase (default 10s), not
	// each probe. On expiry, successful samples are kept, a warning is recorded,
	// and the selected load phases still run. Non-positive values use the
	// default. An earlier caller deadline or cancellation stops the whole run.
	// A healthy slow path or a larger IdleProbes count may require a larger
	// IdleTimeout to collect all requested samples and their percentiles.
	IdleTimeout time.Duration
	// Stability holds the draft's algorithm parameters; zero fields use
	// defaults. SendBufferBytes applies to upload phases only and defaults to
	// DefaultUploadSendBuffer there; set it negative to disable.
	Stability StabilityParams
	// HTTPClient supplies the base transport (proxy, TLS config, dialer). Only
	// its Transport is used; each load flow gets its own clone so flows do not
	// share a connection. If the Transport is not an *http.Transport it is used
	// as-is and flows may share connections (a warning is recorded). Custom
	// transports and dialers must honor cancellation and close promptly;
	// Run cannot forcibly stop caller-supplied code. HTTPClient.Timeout is unused.
	HTTPClient *http.Client
	// Logger receives debug logs; nil discards them.
	Logger *slog.Logger
	// Events receives progress notifications as the run proceeds; nil (the
	// default) reports nothing and costs nothing. It is called synchronously
	// from flow and probe goroutines, so calls can and do overlap: the
	// function must be safe for concurrent use and must return promptly
	// (RES-8). See Event for what is delivered.
	Events func(Event)
	// ConfigTimeout bounds config discovery (default 10s).
	ConfigTimeout time.Duration
	// Header is added to every request the test sends (config fetch, probes,
	// load flows): credentials for a protected server, for example
	// Authorization: Bearer <token>. Keys set here override the defaults.
	// It is read from every flow and probe goroutine, so it must not be
	// mutated while Run is in flight.
	Header http.Header
	// contains filtered or unexported fields
}

Options configures a test run. The zero value is valid and uses the defaults.

type ProxyInfo added in v0.2.0

type ProxyInfo struct {
	// Explicit is true when the HTTP transport routed requests via a proxy.
	Explicit bool `json:"explicit"`
	// URL of the explicit proxy, credentials removed.
	URL string `json:"url,omitempty"`
	// TLSInterception is true when the server certificate chain verified but
	// the leaf is not publicly trusted (no Certificate Transparency SCTs): a
	// TLS-inspecting proxy or a private CA re-issued it.
	TLSInterception bool `json:"tls_interception"`
	// Issuer of the intercepted leaf certificate.
	Issuer string `json:"issuer,omitempty"`
	// Reason is a human-readable explanation of the detection.
	Reason string `json:"reason,omitempty"`
}

ProxyInfo describes a detected proxy. Explicit and TLSInterception may both be set.

type ResolvedTarget

type ResolvedTarget struct {
	ConfigURL    string   `json:"config_url"`
	Host         string   `json:"host"`
	TestEndpoint string   `json:"test_endpoint,omitempty"`
	ResolvedIPs  []string `json:"resolved_ips,omitempty"`
	// LocalIPs are the source addresses the test's connections went out on,
	// deduplicated, ports stripped, IPv6 zone IDs kept. They identify the
	// interface/network a stored result was taken on. Under an explicit proxy
	// this is the address used toward the proxy. Empty with a custom
	// RoundTripper or DialTLSContext, which bypass the library's dialer.
	LocalIPs []string `json:"local_ips,omitempty"`
	// HTTPVersion is the protocol negotiated by load flows ("HTTP/2.0", "HTTP/1.1").
	HTTPVersion string       `json:"http_version,omitempty"`
	Config      ServerConfig `json:"config"`
	// Proxy is set when the measured path involves a proxy: an explicit one
	// from the transport's Proxy function, or TLS interception inferred from
	// the certificate chain. Nil when nothing was detected. Numbers are still
	// valid measurements, but of the client→proxy leg.
	Proxy *ProxyInfo `json:"proxy,omitempty"`
}

ResolvedTarget describes the server actually used.

type Result

type Result struct {
	// SchemaVersion is ResultSchemaVersion at the time the result was produced.
	SchemaVersion int            `json:"schema_version"`
	Target        ResolvedTarget `json:"target"`
	StartedAt     time.Time      `json:"started_at"`
	Duration      time.Duration  `json:"duration_ns"`
	// Idle is the idle latency measured on fresh connections before any load.
	// Nil when IdleProbes < 0 or no probe succeeded.
	Idle *LatencyStats `json:"idle,omitempty"`
	// Download / Upload are nil when the direction was not requested.
	Download *DirectionResult `json:"download,omitempty"`
	Upload   *DirectionResult `json:"upload,omitempty"`
	// Cancelled is true when the context was cancelled; the result is partial.
	Cancelled bool     `json:"cancelled"`
	Warnings  []string `json:"warnings,omitempty"`
}

Result is the outcome of a run. It serialises to JSON with stable snake_case names; the CLI's --json output is exactly this struct.

func Run

func Run(ctx context.Context, t Target, o Options) (*Result, error)

Run executes the responsiveness test against t and returns the Result. Set Options.Events to follow the run as it happens.

If ctx is cancelled mid-run, Run returns the partial Result (Cancelled=true) together with ctx.Err(). If a load phase fails before completing an interval, Run returns the partial Result (that direction flagged reason=flow_error, everything measured before it intact) together with the error. Only discovery failures return a nil Result.

type ServerConfig

type ServerConfig struct {
	Version          int    `json:"version"`
	TestEndpoint     string `json:"test_endpoint,omitempty"`
	SmallDownloadURL string `json:"small_download_url"`
	LargeDownloadURL string `json:"large_download_url"`
	UploadURL        string `json:"upload_url"`
}

ServerConfig is the parsed JSON configuration document served by a target.

type StabilityParams

type StabilityParams struct {
	// MovingAverageDistance (MAD): number of intervals in the moving average.
	MovingAverageDistance int
	// Interval (ID): how often stability is re-evaluated and flows are added.
	Interval time.Duration
	// TrimmedMeanPercent (TMP): single-sided trimmed-mean percentile for latency.
	TrimmedMeanPercent float64
	// StdDevTolerance (SDT): stability is declared when the standard deviation of
	// the last MAD moving averages is below this fraction of the current one.
	StdDevTolerance float64
	// InitialFlows (INP) and FlowIncrement (INC). The ramp doubles the flow
	// count at each step; FlowIncrement is the floor of a step.
	InitialFlows  int
	FlowIncrement int
	// MaxProbesPerSecond (MPS) and ProbeCapacityPercent (PTC).
	MaxProbesPerSecond   int
	ProbeCapacityPercent float64

	// SendBufferBytes is the per-flow credit the transport accepts before
	// bytes reach the wire (HTTP/2 stream window plus socket buffer). An
	// interval in which the credit of newly opened flows exceeds
	// StdDevTolerance of the interval's goodput is a drain interval: it is
	// neither measured nor used for decisions. 0 (the default) disables it;
	// the I/O layer sets it for uploads, where bytes are counted on hand-over.
	SendBufferBytes int64
	// RampGainTolerance stops the flow ramp: after an add, if goodput grew by
	// less than this fraction the link is saturated and no more flows open.
	// Negative never stops the ramp (flows are added up to the maximum).
	RampGainTolerance float64
	// ChangeTolerance restarts goodput stability tracking when a hold
	// interval's goodput drops by more than this fraction below the moving
	// average (a capacity change mid-run).
	ChangeTolerance float64
}

StabilityParams are the draft's algorithm parameters (Section 5.2). Every zero field selects its default, which is the draft-09 value except for Interval: 1s instead of 5s, so that a phase can stabilise within the 12s MaxDuration budget (see README "Deviations").

type StageMedians

type StageMedians struct {
	DNS       time.Duration `json:"dns_ns"`
	Connect   time.Duration `json:"connect_ns"`
	TLS       time.Duration `json:"tls_ns"`
	TLSPerRTT time.Duration `json:"tls_per_rtt_ns"`
	TTFB      time.Duration `json:"ttfb_ns"`
}

StageMedians holds median per-stage timings from net/http/httptrace. TLS is the raw handshake time; TLSPerRTT is the same value normalised to a single round trip (TLS 1.3 = 1 RTT, TLS 1.2 = 2 RTTs) as the draft requires.

type Target

type Target struct {
	// ConfigURL is the URL of the JSON configuration document, e.g.
	// https://example.com/.well-known/nq or a vendor-specific path.
	ConfigURL string `json:"config_url"`
}

Target identifies a responsiveness server by its configuration URL.

func WellKnown

func WellKnown(host string) Target

WellKnown returns a Target for host's /.well-known/nq document (draft Section 8.1). host may include a port.

type TruncationReason

type TruncationReason string

TruncationReason says why a phase ended before stabilising.

const (
	ReasonNone        TruncationReason = ""
	ReasonBytesCap    TruncationReason = "bytes_cap"
	ReasonDurationCap TruncationReason = "duration_cap"
	ReasonCancelled   TruncationReason = "cancelled"
	ReasonFlowError   TruncationReason = "flow_error"
)

Directories

Path Synopsis
cmd
nq command
Command nq measures throughput, latency and responsiveness (RPM) against a responsiveness test server.
Command nq measures throughput, latency and responsiveness (RPM) against a responsiveness test server.
nqserver command
Command nqserver is a minimal, self-hostable responsiveness test server.
Command nqserver is a minimal, self-hostable responsiveness test server.
internal
buildinfo
Package buildinfo carries version metadata injected at build time via -ldflags "-X github.com/korya/netquality/internal/buildinfo.Version=...".
Package buildinfo carries version metadata injected at build time via -ldflags "-X github.com/korya/netquality/internal/buildinfo.Version=...".
engine
Package engine holds the pure, clock-free measurement logic of netquality: latency statistics, the draft's stability criterion, and (after extraction) the per-interval decisions.
Package engine holds the pure, clock-free measurement logic of netquality: latency statistics, the draft's stability criterion, and (after extraction) the per-interval decisions.
linksim
Package linksim is a deliberately simple fluid model of a network path used to drive the measurement engine in tests.
Package linksim is a deliberately simple fluid model of a network path used to drive the measurement engine in tests.
Package server implements a minimal responsiveness test server following draft-ietf-ippm-responsiveness Section 7 and network-quality/server's SERVER_SPEC.md.
Package server implements a minimal responsiveness test server following draft-ietf-ippm-responsiveness Section 7 and network-quality/server's SERVER_SPEC.md.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL