netquality

package module
v0.1.1 Latest Latest
Warning

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

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

README

netquality CI Go Reference

A Go library and CLI that 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.

  • Standard library only, no CGO, cross-compiles for Windows/macOS/Linux on amd64 and arm64.
  • Every run is bounded by time, bytes and connection count before it starts, and 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, 191.8ms p95, jitter 32.5ms (5 probes)
Download      178.3 Mbps    365 RPM  loaded 185.2ms median, 421.6ms p95  [8 flows, high/high confidence]
Upload        125.9 Mbps    311 RPM  loaded 160.7ms median, 399.6ms p95  [13 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 latencyIdleProbes sequential GETs of the 1-byte resource, 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.
  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
    MaxBytes:    100 << 20,        // per direction
    MaxFlows:    8,
})
if err != nil { /* discovery failed, or ctx cancelled (res is then partial) */ }
fmt.Println(res.Download.RPM, res.Download.ThroughputBPS, res.Download.Truncated)

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

RunWithEvents 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. 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).

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-bytes 100MB --max-flows 8
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. It is correct, not hardened: put it behind something that rate-limits.

Safety limits

Limit Default Effect
MaxDuration 12 s per direction phase ends; if not yet stable → truncated, reason=duration_cap
MaxBytes 250 MiB per direction (probes included) phase ends → reason=bytes_cap
MaxFlows 16 never more concurrent load connections
ctx cancellation all flows stop within ~200 ms; partial result, cancelled=true

There are no retries, no background goroutines after Run returns, and no telemetry.

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 MaxDuration + MaxBytes Runs on metered laptops.
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.
Flow addition every interval every interval until MaxFlows Same, with the cap.
Probe byte accounting foreign 5000 B, self 1000 B (draft's estimates) Counted against MaxBytes and the 5 % capacity rule.
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, 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 + loopback integration (in-process nqserver)
NQ_LIVE=1 go test -run TestLive -v .   # hits Apple and Cloudflare

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    = 250 << 20 // 250 MiB
	DefaultMaxFlows    = 16
	DefaultIdleProbes  = 5
	// DefaultConfigTimeout bounds config discovery, which happens before the
	// per-direction budgets apply.
	DefaultConfigTimeout = 10 * time.Second
)

Default safety limits and probe counts.

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 MAD intervals ran; moving average is partial.
	ConfidenceLow Confidence = "low"
	// ConfidenceMedium: at least MAD 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.
	Loaded LoadedLatency `json:"loaded"`
	// 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"`
	// 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 RunWithEvents' sink. Sinks are called synchronously from test goroutines and must return promptly.

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 LatencySample

type LatencySample struct {
	Total   time.Duration // full wall time of the probe (foreign: incl. connect+TLS; self: request only)
	DNS     time.Duration
	Connect time.Duration // TCP handshake
	TLS     time.Duration // raw TLS handshake time
	TLSRTTs int           // number of round trips the negotiated TLS version needs (0 = no TLS)
	TTFB    time.Duration // request sent -> first response byte
	HTTP    time.Duration // request sent -> full body read (http_f / http_l in the draft)
	Staged  bool          // true when DNS/Connect/TLS/TTFB are populated (fresh connection)
}

LatencySample is one probe measurement.

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"`
	P95     time.Duration `json:"p95_ns"`
	Max     time.Duration `json:"max_ns"`
	Jitter  time.Duration `json:"jitter_ns"`
	Stages  *StageMedians `json:"stages,omitempty"`
}

LatencyStats summarises a set of latency samples.

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).

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 bounds the bytes moved by each direction's load phase, probes
	// included (default 250 MiB). Hitting it truncates the phase.
	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
	// Stability holds the draft's algorithm parameters; zero fields use defaults.
	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).
	HTTPClient *http.Client
	// Logger receives debug logs; nil discards them.
	Logger *slog.Logger
	// ConfigTimeout bounds config discovery (default 10s).
	ConfigTimeout time.Duration
	// contains filtered or unexported fields
}

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

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"`
	// HTTPVersion is the protocol negotiated by load flows ("HTTP/2.0", "HTTP/1.1").
	HTTPVersion string       `json:"http_version,omitempty"`
	Config      ServerConfig `json:"config"`
}

ResolvedTarget describes the server actually used.

type Result

type Result struct {
	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.

If ctx is cancelled mid-run, Run returns the partial Result (Cancelled=true) together with ctx.Err(). Other errors (discovery failure, no usable flows) return a nil Result.

func RunWithEvents

func RunWithEvents(ctx context.Context, t Target, o Options, sink func(Event)) (*Result, error)

RunWithEvents is Run with a progress sink. sink may be nil.

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.

func ParseServerConfig

func ParseServerConfig(data []byte) (*ServerConfig, error)

ParseServerConfig parses a configuration document. It accepts both the draft field names (small_download_url, large_download_url, upload_url) and the older Apple/Cloudflare *_https_* names, preferring the https-prefixed ones when both are present. Unknown fields are ignored. Duplicate keys, a missing mandatory field, a version other than 1, or mismatched hosts make the document invalid.

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).
	InitialFlows  int
	FlowIncrement int
	// MaxProbesPerSecond (MPS) and ProbeCapacityPercent (PTC).
	MaxProbesPerSecond   int
	ProbeCapacityPercent float64
}

StabilityParams are the draft's algorithm parameters (Section 5.2).

func DefaultStabilityParams

func DefaultStabilityParams() StabilityParams

DefaultStabilityParams returns the draft-09 defaults, except Interval, which is 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=...".
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