Shell Scripts
Use these examples to repeat the resource queries from one Bash session. They read
the exported XCSH_* values prepared in Overview and
Query reference. They make read-only API requests against the existing
demo and use Bash, cURL, and jq.
| Example | Output | Queries |
|---|---|---|
| simple-stats.sh | One request-rate summary | One service graph request for the discovered virtual host |
| get-stats.sh | One JSON report with seven resource sections | Complete HTTP log counts, load balancer metrics, exact origin discovery and metrics, application health, four API activity queries, WAF counters, and separate security-event logs and metrics |
Both examples use the fixed query times from Query reference. They do not load
.env; run the shared setup and discovery in the parent session first. The complete
collector requires XCSH_API_DISCOVERY_ENABLED=true and the discovered
XCSH_APP_TYPE. Its origin matching supports the current demo’s single default
pool with one public-IP origin and one graph source. Repeat
origin discovery if that
identity is unavailable or ambiguous.
Download and run
Section titled “Download and run”-
Complete Overview setup and shared identifier discovery in one Bash session. Refresh all three query times for a later run.
-
Download the examples into private local files. Keep output private using the shared response and privacy conventions.
Terminal window mkdir -p statistics-outputchmod 700 statistics-outputcd statistics-outputcurl --silent --show-error --fail --connect-timeout 10 --max-time 60 \--output simple-stats.sh \https://f5-sales-demo.github.io/statistics/assets/scripts/simple-stats.shcurl --silent --show-error --fail --connect-timeout 10 --max-time 60 \--output get-stats.sh \https://f5-sales-demo.github.io/statistics/assets/scripts/get-stats.shchmod 700 simple-stats.sh get-stats.sh -
Run the request-rate example and inspect its JSON summary.
Terminal window ./simple-stats.sh > simple-stats.jsonjq . simple-stats.json -
Run the complete collector with the same exported window and inspect a section.
Terminal window ./get-stats.sh > get-stats.jsonjq .http_access_logs get-stats.jsonjq .origin_performance get-stats.json
An unsuccessful run returns nonzero and writes a concise section-specific error to standard error. It emits no partial report or raw error body. A redirected output file can therefore be empty after failure; check the command’s exit status before reading or using it. Follow Troubleshooting for access, identity, and telemetry delays.
The Download personalized script control beside each source embeds your
noncredential settings from the shared form. Run shared setup and discovery in
the parent Bash session first; query times and discovered identifiers remain
runtime values. Personalized files require an environment-provided
XCSH_API_TOKEN and never include the token entered in the form. The canonical
download links above always return the original scripts.
Request-rate source
Section titled “Request-rate source”The expanded source below is imported from the same executable file as the
download. It requests only HTTP_REQUEST_RATE and selects the exact discovered
virtual host.
#!/usr/bin/env bashset -euo pipefail
# Run Overview setup and Query reference discovery in the parent Bash session.for name in XCSH_API_URL XCSH_API_TOKEN XCSH_NAMESPACE XCSH_VIRTUAL_HOST \ XCSH_START_TIME XCSH_END_TIME; do if [[ -z "${!name:-}" ]]; then printf 'Request rate: export %s using Overview and Query reference.\n' "$name" >&2 exit 1 fidoneif ! jq -en --arg start "$XCSH_START_TIME" --arg end "$XCSH_END_TIME" \ '$start | test("^[0-9]+$")' >/dev/null 2>&1 || ! jq -en --arg start "$XCSH_START_TIME" --arg end "$XCSH_END_TIME" \ '($end | test("^[0-9]+$")) and (($start | tonumber) < ($end | tonumber))' >/dev/null 2>&1; then printf 'Request rate: refresh the query window in Query reference.\n' >&2 exit 1fitrap 'printf "Request rate: invalid response or query.\n" >&2' ERR
request="$(jq -nc --arg ns "$XCSH_NAMESPACE" --arg vh "$XCSH_VIRTUAL_HOST" \ --arg start "$XCSH_START_TIME" --arg end "$XCSH_END_TIME" '{ namespace: $ns, start_time: $start, end_time: $end, step: "5m", range: "5m", group_by: ["VHOST"], label_filter: [{label: "LABEL_VHOST", op: "EQ", value: $vh}], field_selector: {node: {metric: {upstream: ["HTTP_REQUEST_RATE"]}}} }')"if ! response="$(curl --silent --connect-timeout 10 --max-time 60 \ --request POST --header "Authorization: APIToken ${XCSH_API_TOKEN}" \ --header 'Content-Type: application/json' --data-binary "$request" \ --write-out $'\n%{http_code}' \ "${XCSH_API_URL%/}/api/data/namespaces/${XCSH_NAMESPACE}/graph/service" 2>/dev/null)"; then printf 'Request rate: HTTP transport failed.\n' >&2 exit 1fistatus="${response##*$'\n'}"if [[ ! "$status" =~ ^2[0-9][0-9]$ ]]; then printf 'Request rate: HTTP request failed. Check access in Overview.\n' >&2 exit 1fibody="$(jq -cse 'if length == 1 and (.[0] | type == "object") then .[0] else error("Expected one JSON object") end' \ <<<"${response%$'\n'*}" 2>/dev/null)"report="$(jq -c --arg vh "$XCSH_VIRTUAL_HOST" ' def finite: if type == "number" or type == "string" then (try tonumber catch null) | select(type == "number") | select(isfinite and (isnan | not)) else empty end; [.data.nodes[]? | select(.id.vhost == $vh) | .data.metric.upstream[]? | select(.type == "HTTP_REQUEST_RATE")] | if length > 1 then error("Ambiguous request-rate series") else .[0] end | {metric: (.type // "HTTP_REQUEST_RATE"), unit, points: (.value.raw | length), latest: (.value.raw[-1] // null), maximum: ([.value.raw[]?.value | finite] | max)} ' <<<"$body" 2>/dev/null)"printf '%s\n' "$report"Complete collector source
Section titled “Complete collector source”The seven labeled sections follow the resource guides sequentially. HTTP logs use
a scroll snapshot with 100 records per page and a 100-page cap. Every record must
match the selected namespace and virtual host, and counted must equal
total_hits. The script retains only response codes, classes, and methods from
logs. Narrow the window if the page cap prevents a complete count.
#!/usr/bin/env bashset -euo pipefail
# Export shared setup/discovery first. This example never loads a file or probes traffic.section='Setup'fail() { printf '%s: %s\n' "$section" "$1" >&2 exit 1}for name in XCSH_API_URL XCSH_API_TOKEN XCSH_NAMESPACE XCSH_LB_NAME \ XCSH_VIRTUAL_HOST XCSH_START_TIME XCSH_END_TIME XCSH_START_24H; do [[ -n "${!name:-}" ]] || fail "export $name using Overview and Query reference."donesection='API activity'[[ "${XCSH_API_DISCOVERY_ENABLED:-}" == true ]] || fail 'API discovery must be enabled; repeat identifier discovery in Query reference.'[[ -n "${XCSH_APP_TYPE:-}" ]] || fail 'export XCSH_APP_TYPE from the load balancer label in Query reference.'section='Setup'jq -en --arg start "$XCSH_START_TIME" --arg end "$XCSH_END_TIME" \ --arg day "$XCSH_START_24H" ' all([$start, $end, $day][]; test("^[0-9]+$")) and (($day | tonumber) < ($start | tonumber)) and (($start | tonumber) < ($end | tonumber))' >/dev/null 2>&1 || fail 'refresh the query window in Query reference.'trap 'printf "%s: invalid response or query.\n" "$section" >&2' ERR
# Only HTTP handling and graph summaries are shared between the labeled sections.api() { local method="$1" path="$2" payload="${3:-}" response status local -a args=(--silent --connect-timeout 10 --max-time 60 --request "$method" --header "Authorization: APIToken ${XCSH_API_TOKEN}" --header 'Accept: application/json' --write-out $'\n%{http_code}') if [[ -n "$payload" ]]; then args+=(--header 'Content-Type: application/json' --data-binary "$payload") fi response="$(curl "${args[@]}" "${XCSH_API_URL%/}${path}" 2>/dev/null)" || fail 'HTTP transport failed.' status="${response##*$'\n'}" [[ "$status" =~ ^2[0-9][0-9]$ ]] || fail 'HTTP request failed; check access in Overview.' body="$(jq -cse 'if length == 1 and (.[0] | type == "object") then .[0] else error("Expected one JSON object") end' \ <<<"${response%$'\n'*}" 2>/dev/null)" || fail 'expected one valid JSON response object.'}graph_summary() { jq -c --arg vh "$XCSH_VIRTUAL_HOST" --arg src "${source:-}" \ --arg dst "${destination:-}" --argjson expected "$1" ' def finite: if type == "number" or type == "string" then (try tonumber catch null) | select(type == "number") | select(isfinite and (isnan | not)) else empty end; def summary: {type, unit, points: (.value.raw | length), samples: (.value.raw // []), latest: (.value.raw[-1] // null), maximum: ([.value.raw[]?.value | finite] | max)}; [.data.nodes[]? | select(.id.vhost == $vh and ($dst == "" or .id.service == $dst)) | .data.metric.upstream[]?] as $node | [.data.edges[]? | select(.dst_id.vhost == $vh and .src_id.service == $src and .dst_id.service == $dst) | .data.metric.data[]?] as $edge | {step, metrics: ($node | map(summary) | sort_by(.type)), unavailable: ($expected - [$node[].type])} + if $dst == "" then {} else {source: $src, destination: $dst, edge: ($edge | map(summary) | sort_by(.type)), edge_unavailable: ($expected - [$edge[].type])} end ' <<<"$body" 2>/dev/null}base="$(jq -nc --arg ns "$XCSH_NAMESPACE" --arg start "$XCSH_START_TIME" \ --arg end "$XCSH_END_TIME" '{namespace: $ns, start_time: $start, end_time: $end, step: "5m", range: "5m"}')"graph_base="$(jq -c --arg vh "$XCSH_VIRTUAL_HOST" '. + { group_by: ["VHOST"], label_filter: [{label: "LABEL_VHOST", op: "EQ", value: $vh}]}' <<<"$base")"graph_path="/api/data/namespaces/${XCSH_NAMESPACE}/graph/service"
# 1. HTTP access logs: complete fixed-window snapshot, outcome fields only.section='HTTP access logs'query="$(jq -nr --arg vh "$XCSH_VIRTUAL_HOST" '"{vh_name=" + ($vh | tojson) + "}"')"request="$(jq -c --arg query "$query" 'del(.step, .range) + { limit: 100, sort: "DESCENDING", scroll: true, query: $query}' <<<"$base")"api POST "/api/data/namespaces/${XCSH_NAMESPACE}/access_logs" "$request"total="$(jq -er '.total_hits | tonumber | select(isfinite and . >= 0 and . == floor)' \ <<<"$body" 2>/dev/null)"rows='[]'pages=0while :; do pages="$((pages + 1))" page="$(jq -ec --arg ns "$XCSH_NAMESPACE" --arg vh "$XCSH_VIRTUAL_HOST" ' if (.logs | type) != "array" then error("Expected log array") else [.logs[] | (if type == "string" then fromjson else . end) | if .namespace == $ns and .vh_name == $vh then {rsp_code, rsp_code_class, method} else error("Scope mismatch") end] end ' <<<"$body" 2>/dev/null)" || fail 'invalid logs or records outside the selected scope.' count="$(jq 'length' <<<"$page")" ((count <= 100)) || fail 'page exceeds the 100-record limit.' rows="$(jq -nc --argjson rows "$rows" --argjson page "$page" '$rows + $page')" counted="$(jq 'length' <<<"$rows")" ((counted <= total)) || fail 'record count exceeds total_hits.' ((count > 0)) || break # Equality also permits a complete snapshot at the page cap without a 101st request. ((pages < 100)) || { ((counted == total)) || fail '100-page cap reached before total_hits; use a narrower window.' break } cursor="$(jq -er '.scroll_id // "" | select(type == "string")' <<<"$body" 2>/dev/null)" [[ -n "$cursor" ]] || break request="$(jq -nc --arg ns "$XCSH_NAMESPACE" --arg cursor "$cursor" \ '{namespace: $ns, scroll_id: $cursor}')" api POST "/api/data/namespaces/${XCSH_NAMESPACE}/access_logs/scroll" "$request"done((counted == total)) || fail 'incomplete pagination: counted records differ from total_hits.'logs="$(jq -c --argjson total "$total" --argjson pages "$pages" ' {scope: "complete_window", total_hits: $total, pages: $pages, counted: length, response_codes: (sort_by(.rsp_code) | group_by(.rsp_code) | map({code: .[0].rsp_code, count: length})), response_classes: (sort_by(.rsp_code_class) | group_by(.rsp_code_class) | map({class: .[0].rsp_code_class, count: length})), methods: (sort_by(.method) | group_by(.method) | map({method: .[0].method, count: length}))}' <<<"$rows")"
# 2. Load balancer metrics: VHOST grouping retains ingress/egress selectors.section='Load balancer metrics'metrics='["HTTP_REQUEST_RATE","HTTP_ERROR_RATE","HTTP_ERROR_RATE_4XX", "HTTP_ERROR_RATE_5XX","HTTP_RESPONSE_LATENCY","REQUEST_THROUGHPUT", "RESPONSE_THROUGHPUT","REQUEST_TO_ORIGIN_RATE","CLIENT_RTT","SERVER_RTT", "HTTP_SERVER_DATA_TRANSFER_TIME","HTTP_INGRESS_REQUEST_RATE","HTTP_EGRESS_REQUEST_RATE", "HTTP_RESPONSE_LATENCY_PERCENTILE_50","HTTP_RESPONSE_LATENCY_PERCENTILE_90", "HTTP_RESPONSE_LATENCY_PERCENTILE_99","ACTIVE_CONNECTIONS","NEW_CONNECTION_RATE"]'request="$(jq -c --argjson metrics "$metrics" '. + { field_selector: {node: {metric: {upstream: $metrics}}}}' <<<"$graph_base")"api POST "$graph_path" "$request"traffic="$(graph_summary "$metrics")"
# 3. Origin performance: require one configured public-IP origin and one exact edge.section='Origin performance'api GET "/api/config/namespaces/${XCSH_NAMESPACE}/http_loadbalancers/${XCSH_LB_NAME}"pool="$(jq -er --arg ns "$XCSH_NAMESPACE" ' [.spec.default_route_pools[]?.pool] | unique | if length == 1 and ((.[0].namespace // $ns) == $ns) and (.[0].name | type == "string" and length > 0) then .[0].name else error("Expected one pool") end' <<<"$body" 2>/dev/null)" || fail 'expected one default origin pool in the selected namespace.'api GET "/api/config/namespaces/${XCSH_NAMESPACE}/origin_pools/${pool}"origin="$(jq -er '.spec.origin_servers | if length == 1 and (.[0].public_ip.ip | type == "string" and length > 0) then .[0].public_ip.ip else error("Expected one public-IP origin") end' \ <<<"$body" 2>/dev/null)" || fail 'expected one configured public-IP origin.'request="$(jq -c '. + {group_by: ["VHOST", "SERVICE"], field_selector: { node: {metric: {upstream: ["HTTP_REQUEST_RATE"]}}, edge: {metric: {types: ["HTTP_REQUEST_RATE"]}}}}' <<<"$graph_base")"api POST "$graph_path" "$request"match="$(jq -ec --arg vh "$XCSH_VIRTUAL_HOST" --arg ip "$origin" ' [.data.edges[]? | select(.dst_id.vhost == $vh and .dst_id.service == ("S:" + $ip)) | {source: .src_id.service, destination: .dst_id.service}] | unique | if length == 1 and (.[0].source | type == "string" and length > 0) then .[0] else error("No unique configured origin edge") end' \ <<<"$body" 2>/dev/null)" || fail 'no unique graph edge matches the configured origin; repeat origin discovery.'source="$(jq -r '.source' <<<"$match")"destination="$(jq -r '.destination' <<<"$match")"metrics='["HTTP_REQUEST_RATE","HTTP_ERROR_RATE","HTTP_ERROR_RATE_4XX", "HTTP_ERROR_RATE_5XX","HTTP_RESPONSE_LATENCY","REQUEST_THROUGHPUT", "RESPONSE_THROUGHPUT","REQUEST_TO_ORIGIN_RATE","CLIENT_RTT","SERVER_RTT", "HTTP_SERVER_DATA_TRANSFER_TIME"]'request="$(jq -c --argjson metrics "$metrics" '. + { group_by: ["VHOST", "SERVICE"], field_selector: { node: {metric: {upstream: $metrics}}, edge: {metric: {types: $metrics}}}} ' <<<"$graph_base")"api POST "$graph_path" "$request"origin_metrics="$(graph_summary "$metrics")"
# 4. Application health: retain returned score samples without inventing a unit.section='Application health'request="$(jq -c '. + {field_selector: {node: {healthscore: {types: [ "HEALTHSCORE_CONNECTIVITY","HEALTHSCORE_PERFORMANCE","HEALTHSCORE_SECURITY", "HEALTHSCORE_RELIABILITY","HEALTHSCORE_OVERALL"]}}}}' <<<"$graph_base")"api POST "$graph_path" "$request"health="$(jq -c --arg vh "$XCSH_VIRTUAL_HOST" ' [.data.nodes[]? | select(.id.vhost == $vh) | .data.healthscore.data[]?] as $scores | {step, healthscores: ($scores | map({type, points: (.value | length), samples: (.value // []), latest: (.value[-1] // null)}) | sort_by(.type)), unavailable: (["HEALTHSCORE_CONNECTIVITY","HEALTHSCORE_PERFORMANCE", "HEALTHSCORE_SECURITY","HEALTHSCORE_RELIABILITY","HEALTHSCORE_OVERALL"] - [$scores[].type])}' <<<"$body" 2>/dev/null)"
# 5. API activity: two fixed-window summaries and two discovery snapshots.section='API activity'api_path="/api/ml/data/namespaces/${XCSH_NAMESPACE}/virtual_hosts/${XCSH_VIRTUAL_HOST}/api_endpoints"request="$(jq -nc --arg ns "$XCSH_NAMESPACE" --arg vh "$XCSH_VIRTUAL_HOST" \ --arg start "$XCSH_START_TIME" --arg end "$XCSH_END_TIME" '{ namespace: $ns, name: $vh, apiep_summary_filter: {start_time: $start, end_time: $end}}')"api POST "${api_path}/summary/calls_by_response_code" "$request"calls="$(jq -c '{total_calls, request_count_per_rsp_code}' <<<"$body")"request="$(jq -c '. + {topk: 5, top_by_metric: "ACTIVITY_METRIC_TYPE_REQ_PERCENTAGE"}' <<<"$request")"api POST "${api_path}/summary/top_active" "$request"top="$(jq -c '{top_apieps: [.top_apieps[]? | {apiep_url, method, top_by_metric_value}]}' <<<"$body")"api GET "${api_path}/stats"stats="$(jq -c '{total_endpoints, inventory, discovered, shadow, pii_detected}' <<<"$body")"api GET "/api/ml/data/namespaces/${XCSH_NAMESPACE}/app_types/${XCSH_APP_TYPE}/api_endpoints"inventory="$(jq -c '{last_update, count: (.apiep_list | length), categories: ([.apiep_list[]?.category] | group_by(.) | map({category: .[0], count: length}))}' \ <<<"$body" 2>/dev/null)"
# 6. WAF metrics: retain matching counter buckets and distinguish unavailable totals.section='WAF metrics'request="$(jq -c '. + {field_selector: ["TOTAL_REQUESTS","ATTACKED_REQUESTS", "BLOCKED_REQUESTS","BOT_DETECTION"], group_by: ["VIRTUAL_HOST"]}' <<<"$base")"api POST "/api/data/namespaces/${XCSH_NAMESPACE}/app_firewall/metrics" "$request"waf="$(jq -c --arg vh "$XCSH_VIRTUAL_HOST" ' def finite: if type == "number" or type == "string" then (try tonumber catch null) | select(type == "number") | select(isfinite and (isnan | not)) else empty end; {step, metrics: [.data[]? | {type} + (if has("unit") then {unit} else {} end) + { series: [.data[]? | select(.key.VIRTUAL_HOST == $vh) | {key, samples: (.value // []), points: (.value | length), latest: (.value[-1] // null)}], total: ([.data[]? | select(.key.VIRTUAL_HOST == $vh) | .value[]?.value] as $values | [$values[] | finite] as $finite | if ($values | length) > 0 and ($finite | length) == ($values | length) then ($finite | add) else null end)}], unavailable: (["TOTAL_REQUESTS","ATTACKED_REQUESTS","BLOCKED_REQUESTS","BOT_DETECTION"] - [.data[]? | select(any(.data[]?; .key.VIRTUAL_HOST == $vh)) | .type])} ' <<<"$body" 2>/dev/null)"
# 7. Security events: bounded block logs and a separate fixed-window metric query.section='Security events'query="$(jq -nr --arg vh "$XCSH_VIRTUAL_HOST" \ '"{vh_name=" + ($vh | tojson) + ",action=\"block\"}"')"request="$(jq -nc --arg ns "$XCSH_NAMESPACE" --arg start "$XCSH_START_24H" \ --arg end "$XCSH_END_TIME" --arg query "$query" '{namespace: $ns, start_time: $start, end_time: $end, limit: 3, sort: "DESCENDING", query: $query}')"api POST "/api/data/namespaces/${XCSH_NAMESPACE}/app_security/events" "$request"events="$(jq -c --arg ns "$XCSH_NAMESPACE" --arg vh "$XCSH_VIRTUAL_HOST" ' {total_hits, returned: (.events | length), events: [.events[]? | (if type == "string" then fromjson else . end) | if .namespace == $ns and .vh_name == $vh and .action == "block" then {time, namespace, vh_name, action, sec_event_type, sec_event_name, rsp_code, app_firewall_name} else error("Event scope mismatch") end]}' <<<"$body" 2>/dev/null)" || fail 'invalid events or records outside the selected block-event scope.'request="$(jq -c --arg vh "$XCSH_VIRTUAL_HOST" 'del(.range) + { group_by: ["VH_NAME","SEC_EVENT_TYPE"], label_filter: [{label: "VH_NAME", op: "EQ", value: $vh}]} ' <<<"$base")"api POST "/api/data/namespaces/${XCSH_NAMESPACE}/app_security/metrics" "$request"security_metrics="$(jq -c '{data, step}' <<<"$body")"
# Print only after every query and completeness check succeeds.section='Report'report="$(jq -n --argjson context "$base" --arg vh "$XCSH_VIRTUAL_HOST" \ --arg lb "$XCSH_LB_NAME" --arg app "$XCSH_APP_TYPE" --arg day "$XCSH_START_24H" \ --argjson logs "$logs" --argjson traffic "$traffic" --argjson origin "$origin_metrics" \ --argjson health "$health" --argjson calls "$calls" --argjson top "$top" \ --argjson stats "$stats" --argjson inventory "$inventory" --argjson waf "$waf" \ --argjson events "$events" --argjson security "$security_metrics" '{ context: ($context + {virtual_host: $vh, load_balancer: $lb, app_type: $app, security_events_start_time: $day}), http_access_logs: $logs, load_balancer_metrics: $traffic, origin_performance: $origin, application_health: $health, api_activity: {calls_by_response_code: $calls, top_active: $top, stats: $stats, inventory: $inventory}, waf_metrics: $waf, security_events: {logs: $events, metrics: $security}}' 2>/dev/null)"printf '%s\n' "$report"Interpret the output
Section titled “Interpret the output”simple-stats.sh prints metric, the returned unit, sample count points, the
latest timestamp and string value, and maximum. The maximum uses only finite
numeric samples; "NaN", infinite values, and other nonnumeric values do not
contribute. With no finite samples, maximum is null. With no returned series,
points is zero and unit and latest are null. A sample count is not a request
count.
get-stats.sh emits context with the namespace, discovered identifiers, fixed
two-hour window, five-minute buckets, and the start of the 24-hour security-log
window. Its seven named sections map to the resource guides:
| JSON section | Interpretation |
|---|---|
http_access_logs | Complete logged-request counts for the fixed snapshot; late ingestion and logging coverage still apply |
load_balancer_metrics | Returned units, samples, latest points, finite maxima, and requested types with no returned series in unavailable |
origin_performance | Exact configured destination and source, with separate node and edge summaries |
application_health | Returned health-score samples and unavailable score types; the API supplies no unit field |
api_activity | Calls by response class and top-five activity for the exclusive-end summary window, plus current statistics and inventory snapshots |
waf_metrics | Matching counter buckets and totals; a total is null when samples are absent or any sample is nonnumeric |
security_events | At most three block-event logs over 24 hours, and separate metrics over the two-hour window; total_hits can exceed the returned logs |
Values, units, and timestamps remain as returned. Missing series, empty arrays,
null, and nonnumeric samples represent unavailable telemetry rather than
measured zero. API activity counts cover discovered endpoints and can differ from
HTTP log counts. Security-event metrics can be empty while block logs and WAF
counters are populated.
Script reports belong in private local files. Sanitize any sample before publication using the shared privacy conventions. Existing measured JSON and capture dates on the resource pages remain historical evidence; a new script run does not replace them.