JSON output reference

August 29, 2026 · View on GitHub

Use --json or --output-format json to emit a single JSON document on stdout. Text progress and the human-readable summary are suppressed; stderr may still show progress when it is a TTY (--no-progress to disable).

Schema version: 1.

Every key documented below is always present. Values are null when the key does not apply to the run, so consumers never have to probe for key existence. All latency values are milliseconds.

Top-level fields

FieldTypeDescription
schema_versionintegerJSON schema version. 1 for cryload 6.x. Bumped only on a breaking schema change
cryload_versionstringRelease that produced the report, e.g. "6.0.0"
urlstringTarget URL shown in the report (multi-URL runs use http://first (+N more))
duration_modebooleantrue when the run used -d, otherwise request-count mode
configobjectEffective run configuration
summaryobjectThroughput and error-rate summary
rateobjectRate-limit schedule attainment. Fields are null without --rate
transferobjectResponse body size metrics
latency_msobjectService-time latency aggregates
corrected_latency_msobjectCoordinated-omission-corrected latency aggregates
send_delay_msobjectGap between scheduled and actual send time
phases_msobjectPer-phase breakdown: dns, connect, tls, ttfb, total
latency_histogramarrayRolled-up histogram buckets
statusobjectHTTP success/failure breakdown
by_statusarrayPer-HTTP-status-code breakdown with latency
by_urlarray | nullPer-URL breakdown. null for single-URL runs and for lists over 1000 URLs
thresholdsobjectEvery evaluated CI gate and its result
verdictobjectFinal exit code and reason

config

FieldTypeDescription
workersintegerOS threads running the load fibers (--workers)
connectionsintegerConcurrent connections (-c)
rate_limitnumber | nullRequested req/s (--rate), null when unlimited
latency_correctionbooleantrue when threshold gates use corrected latency
keepalivebooleanfalse when --disable-keepalive was used
request_timeout_secondsnumber | nullTotal per-request deadline (--request-timeout)
timeout_secondsnumber | nullConnect/read/write timeout (--timeout)

summary

FieldTypeDescription
requestsintegerTotal attempts (responses + transport errors)
responsesintegerCompleted HTTP responses
transport_errorsintegerConnection/TLS/IO failures
elapsed_secondsnumberWall-clock benchmark duration
requests_per_secondnumberrequests / elapsed_seconds
failure_rate_percentnumber(failed HTTP + transport errors) / requests * 100

rate

Present only in a meaningful sense for rate-limited runs. Without --rate every field is null except attained_per_second, which always mirrors summary.requests_per_second.

FieldTypeDescription
requested_per_secondnumber | nullThe --rate value
attained_per_secondnumberActually achieved req/s
attainment_percentnumber | nullattained / requested * 100
scheduled_requestsinteger | nullSchedule slots the run should have issued
skipped_requestsinteger | nullSlots that were never issued because the target could not keep up
schedule_drift_msnumber | nullHow far behind the schedule the run finished

A non-zero skipped_requests means the reported uncorrected latency is optimistic. That is exactly the coordinated omission that corrected_latency_ms accounts for.

transfer

FieldTypeDescription
total_bytesintegerSum of response body bytes
size_per_request_bytesnumberAverage bytes per HTTP response
bytes_per_secondnumberThroughput in bytes/sec

latency_ms

Service time: from the moment the request was actually written to the moment the response finished. Transport errors are excluded.

FieldDescription
avg, min, max, stdevBasic latency stats
p10, p25, p50, p75, p90, p95, p99, p999Percentiles

corrected_latency_ms

Same keys as latency_ms. Reports send delay + service time, so a request that sat in the queue because the target stalled is charged for the wait it caused.

corrected_latency_ms equals latency_ms when --rate is not used. Without --rate there is no request schedule, so there is no send delay to correct for.

This is the object CI gates read by default (--latency-correction on). Use --latency-correction off to gate on latency_ms instead.

send_delay_ms

How long each request waited between its scheduled send time and its actual send time. All zeros for an unrated run.

FieldDescription
avg, min, max, stdevBasic stats
p50, p90, p99, p999Percentiles

phases_ms

One object per phase: dns, connect, tls, ttfb, total. Each has the same keys.

FieldTypeDescription
countintegerNumber of samples for this phase (see below)
avg, min, maxnumberBasic stats
p50, p95, p99numberPercentiles

dns, connect and tls are per connection, not per request. With keep-alive only the first request on a connection pays them, so their count is the number of connections opened — not the number of requests. ttfb and total are per response, so their count matches summary.responses.

tls.count is 0 for plain-HTTP targets.

Use --disable-keepalive to make every request pay setup cost, which moves connect.count up to the request count and folds setup into latency_ms.

latency_histogram[]

FieldTypeDescription
start_msnumberBucket start (inclusive)
end_msnumberBucket end
countintegerRequests in bucket
percentnumberShare of total requests

status

FieldTypeDescription
success_statusesstring[]Configured success code ranges
successful_countintegerResponses counted as success
successful_percentnumberShare of HTTP responses
failed_countintegerHTTP responses outside success ranges
failed_percentnumberShare of HTTP responses
transport_error_percentnumberShare of all attempts
codes[]array{ "code", "count", "percent" } per HTTP status
transport_errors[]array{ "category", "count", "percent", "sample_message" } per error category

Transport error categories

status.transport_errors[].category is one of a fixed vocabulary:

dns_failure, connect_refused, connect_timeout, connect_failed, read_timeout, write_timeout, request_timeout, connection_reset, connection_closed, tls_error, proxy_error, protocol_error, other

sample_message carries one representative raw error string for debugging. Do not match on it; match on category.

by_status[]

FieldTypeDescription
codeintegerHTTP status code
countintegerResponses with this code
percentnumberShare of HTTP responses
avg_ms, p50_ms, p95_ms, p99_msnumberLatency for this code only

Useful for spotting a fast-failing 503 path hiding behind an acceptable global p99.

by_url[]

null when the run has a single target URL, and null when the target list has more than 1000 URLs (per-URL histograms would be wasteful at that size). An array otherwise.

FieldTypeDescription
urlstringTarget URL
requestsintegerAttempts against this URL
responsesintegerCompleted responses
transport_errorsintegerTransport failures
okintegerResponses counted as success
failedintegerResponses outside success ranges
failure_rate_percentnumberFailure share for this URL
requests_per_secondnumberThroughput for this URL
avg_ms, min_ms, max_msnumberLatency stats
p50_ms, p95_ms, p99_msnumberPercentiles

--url-threshold requires by_url to be active. Combining it with a target list over 1000 URLs is a config error (exit 2).

thresholds

FieldTypeDescription
passedbooleantrue when no configured gate was breached
evaluated[]arrayEvery gate that ran, passing or not
breached[]arraySubset of evaluated[] with passed: false

Each entry of evaluated[] and breached[]:

FieldTypeDescription
namestringFlag name, e.g. max-p99, min-rps, max-fail-rate
scopestringglobal, or the --url-threshold pattern for per-endpoint gates
metricstringMetric actually compared, e.g. corrected_p99 when correction is on
comparatorstring<= or >=
limitnumberConfigured limit
actualnumberObserved value
passedbooleanGate result

Read metric when a gate result surprises you: with --latency-correction on a max-p99 gate reports corrected_p99, not p99.

verdict

FieldTypeDescription
exit_codeinteger0, 1, 2, 3, 130 or 143
reasonstringok, threshold_breached, target_unreachable, interrupted, terminated

Migrating from the v5 schema

Added top-level keys:

KeyNotes
schema_version1. Gate your parser on this
cryload_versionProducing release
configEffective run configuration, including workers
rateRate attainment. null fields without --rate
corrected_latency_msCoordinated-omission-corrected latency; equals latency_ms without --rate
send_delay_msScheduled-vs-actual send gap
phases_msdns / connect / tls / ttfb / total
by_statusPer-status-code latency
by_urlPer-URL breakdown, null for single-URL and >1000-URL runs
thresholdsEvaluated CI gates
verdictExit code and reason

Nothing was removed or renamed: every v5 key (url, duration_mode, summary, transfer, latency_ms, latency_histogram, status) keeps its name, type and meaning.

Changed values:

  • status.transport_errors[].category no longer contains Crystal exception class names such as IO::TimeoutError or Socket::ConnectError. It now contains a normalized category from the fixed vocabulary listed above. A consumer matching on class names must be updated.
  • A gate on latency_ms.p99 in your own jq check still works, but cryload's own --max-p99 now compares corrected_latency_ms.p99 by default. Use --latency-correction off to restore v5 gate semantics.

Example

cryload http://localhost:3000/api -n 500 --json > result.json
jq '.summary.requests, .latency_ms.p99, .status.successful_count' result.json

CI checks

cryload http://localhost:3000/api -d 30s -q 500 --json > result.json

# Schema guard: fail loudly if a future cryload changes the shape
jq -e '.schema_version == 1' result.json

# Error budget
jq -e '.summary.failure_rate_percent <= 1' result.json

# Corrected p99 gate — the honest one. Uncorrected p99 can look fine
# while the target stalls and drops schedule slots.
jq -e '.corrected_latency_ms.p99 <= 250' result.json

# Rate attainment gate: the run must have actually achieved the rate it asked for
jq -e '.rate.attainment_percent >= 99' result.json
jq -e '.rate.skipped_requests == 0' result.json

Or let cryload do the gating and read the verdict:

cryload http://localhost:3000/api -d 30s -q 500 \
  --max-p99 250 --fail-on-rate-miss --json > result.json

# Which gate failed, and what did it actually compare?
jq -r '.thresholds.breached[] | "\(.name) \(.metric) \(.actual) > \(.limit)"' result.json
jq -r '.verdict | "exit \(.exit_code): \(.reason)"' result.json

Per-endpoint results on a multi-URL run:

cryload --urls-file urls.txt -d 30s --json > result.json
jq -r '.by_url[] | "\(.url) p99=\(.p99_ms) rps=\(.requests_per_second)"' result.json

Where did the latency go?

jq -r '.phases_ms | to_entries[] | "\(.key) count=\(.value.count) p95=\(.value.p95)"' result.json

Remember that dns/connect/tls counts are per connection, so a keep-alive run shows a handful of samples next to hundreds of thousands of ttfb samples.

See also README for a GitHub Actions workflow example, and docs/examples.md for a command cookbook.