Chapter 22Lesson 02~260 minutes

Listeners, Result Collection, Memory Cost, and Safe Debugging: Guided Hands-On Workflow

The workflow intentionally exaggerates result-retention differences with a synthetic ~8 KiB JSON response. The target work is trivial and loopback-only. A tiny GUI run uses View Results Tree and XML response-body retention for forensic debugging; the lean CLI run disables GUI listeners and saves metadata-only CSV. We then quantify what changed on the generator and what did not change on the target.

View Results TreeXML response dataLean CSVHeap/CPUPrivacy scan

Learning objectives

  • Run a tiny GUI debug profile safely with View Results Tree.
  • Configure deliberately heavy XML result retention for only five synthetic samples.
  • Disable heavy listeners and execute a bounded lean CLI profile.
  • Measure JTL size, sample count, response-data nodes, fake-token/email exposure, generator memory/CPU, and target events.
  • Use Summary Report/Simple Data Writer appropriately without confusing them with the main load path.
  • Preserve jmeter.log separately in every CLI run.

1. Safety envelope

Only http://127.0.0.1:8022. Debug profile = 1 thread ×5 loops in GUI. Lean profile = 2 threads ×20 loops = 40 samples in CLI. Response body is synthetic only. Abort on non-loopback host, any real token/email, >40 work requests in a lean run, falling generator headroom, unexpected JTL path, or repeated target/sample errors.

2. Create the synthetic response fixture

Save fixtures/listener_fixture.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse, parse_qs
import argparse
import json
import re
import threading
import time

FIXTURE_VERSION = "prompt22-listener-fixture-v1"
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
PADDING = "X" * 8192

lock = threading.Lock()
event_log = None
metrics = {
    "requests": 0,
    "errors": 0,
    "work_requests": 0,
    "by_run": {},
    "by_thread": {},
}

def now_ms():
    return int(time.time() * 1000)

def log_event(event):
    if event_log is None:
        return
    with lock:
        with event_log.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps(event, sort_keys=True) + "\n")

class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def send_json(self, status, payload):
        raw = json.dumps(payload, sort_keys=True).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.send_header("X-Fixture-Version", FIXTURE_VERSION)
        self.end_headers()
        self.wfile.write(raw)

    def record(self, started, operation, status, **extra):
        with lock:
            metrics["requests"] += 1
            if status >= 400:
                metrics["errors"] += 1
            if operation == "work" and status == 200:
                metrics["work_requests"] += 1
            run_id = extra.get("run_id", "")
            thread_id = extra.get("thread", "")
            if run_id:
                metrics["by_run"][run_id] = metrics["by_run"].get(run_id, 0) + 1
            if thread_id:
                metrics["by_thread"][thread_id] = metrics["by_thread"].get(thread_id, 0) + 1
        event = {
            "ts_ms": now_ms(),
            "operation": operation,
            "status": status,
            "service_wall_ms": now_ms() - started,
        }
        event.update(extra)
        log_event(event)

    def do_GET(self):
        started = now_ms()
        parsed = urlparse(self.path)

        if parsed.path == "/health":
            self.send_json(200, {"status": "ok", "fixture_version": FIXTURE_VERSION})
            self.record(started, "health", 200)
            return

        if parsed.path == "/stats":
            with lock:
                snapshot = json.loads(json.dumps(metrics))
            self.send_json(200, {"fixture_version": FIXTURE_VERSION, "metrics": snapshot})
            self.record(started, "stats", 200)
            return

        if parsed.path != "/work":
            self.send_json(404, {"status": "not_found"})
            self.record(started, "unknown", 404)
            return

        q = parse_qs(parsed.query)
        run_id = q.get("run_id", [""])[0]
        thread_id = q.get("thread", [""])[0]
        seq_raw = q.get("seq", [""])[0]

        if not SAFE_TOKEN.fullmatch(run_id) or not SAFE_TOKEN.fullmatch(thread_id):
            self.send_json(400, {"status": "invalid_metadata"})
            self.record(started, "work", 400, run_id=run_id, thread=thread_id)
            return
        try:
            seq = int(seq_raw)
        except ValueError:
            seq = -1
        if not 1 <= seq <= 1000:
            self.send_json(400, {"status": "invalid_seq"})
            self.record(started, "work", 400, run_id=run_id, thread=thread_id)
            return

        # All identity/token-looking values are intentionally fake/synthetic.
        response = {
            "status": "ok",
            "run_id": run_id,
            "thread": thread_id,
            "seq": seq,
            "email": f"{thread_id.lower()}@example.invalid",
            "access_token": f"FAKE-TOKEN-{run_id}-{thread_id}-{seq}",
            "padding": PADDING,
        }
        self.send_json(200, response)
        self.record(started, "work", 200, run_id=run_id, thread=thread_id, seq=seq)

    def log_message(self, format, *args):
        return

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8022)
    parser.add_argument("--log", default="results/server-events.jsonl")
    args = parser.parse_args()

    global event_log
    event_log = Path(args.log).resolve()
    event_log.parent.mkdir(parents=True, exist_ok=True)
    event_log.write_text("", encoding="utf-8")

    print(f"fixture_version={FIXTURE_VERSION}")
    print(f"listen=http://{args.host}:{args.port}")
    print(f"event_log={event_log}")
    ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()

if __name__ == "__main__":
    main()

Start:

python .\fixtures\listener_fixture.py `
  --host 127.0.0.1 `
  --port 8022 `
  --log .\results\server-events.jsonl

The response contains an 8 KiB padding string plus fake example.invalid email and FAKE-TOKEN-.... Those markers make artifact/privacy differences visible without any real secret or PII.

3. Target and generator preflight

curl --fail --silent http://127.0.0.1:8022/health
curl --fail --silent http://127.0.0.1:8022/stats

Record JMeter/Java versions, free disk, and baseline Java processes. The target work count is the independent reference.

4. Build one plan in GUI

Test Plan
├── HTTP Request Defaults
│   host=${__P(target.host,127.0.0.1)}
│   port=${__P(target.port,8022)}
└── Thread Group
    threads=${__P(threads,1)}
    loops=${__P(loops,1)}
    ├── Counter -> SEQ (per user)
    └── HTTP Work
        GET /work
        run_id=${__P(run.id,p22-local)}
        thread=T${__threadNum}
        seq=${SEQ}
        ├── Constant Timer ${__P(pacing.ms,50)} ms
        ├── JSON JMESPath Assertion: status == ok
        └── [DEBUG ONLY] View Results Tree

Place View Results Tree under the Thread Group so its scope is obvious. It is enabled only for the 1×5 debug run.

5. Deliberately heavy debug result policy

config/heavy-debug.properties:

# Prompt 22 deliberately heavy debug/result-retention profile.
# Use ONLY with the 1-thread x 5-loop local debug run.
target.host=127.0.0.1
target.port=8022
threads=1
loops=5
pacing.ms=50
connect.timeout.ms=500
response.timeout.ms=2000

jmeter.save.saveservice.output_format=xml
jmeter.save.saveservice.response_data=true
jmeter.save.saveservice.response_data.on_error=true
jmeter.save.saveservice.samplerData=true
jmeter.save.saveservice.responseHeaders=true
jmeter.save.saveservice.requestHeaders=true
jmeter.save.saveservice.url=true
jmeter.save.saveservice.hostname=true
jmeter.save.saveservice.encoding=true
jmeter.save.saveservice.bytes=true
jmeter.save.saveservice.sent_bytes=true
jmeter.save.saveservice.thread_counts=true
jmeter.save.saveservice.assertion_results=all
jmeter.save.saveservice.autoflush=false

This combination is intentionally inefficient: XML + response body + sampler data + headers. It exists to show what those fields cost and expose. Do not carry it into the lean/load profile.

6. Tiny GUI debug run

In GUI:

  1. load cli-listener-lab.jmx;
  2. load/apply the heavy debug property settings before startup (for example through -q when launching the GUI or matching listener Save Configuration);
  3. set run.id=p22-debug, 1 thread, 5 loops;
  4. enable View Results Tree;
  5. configure the debug result collector/file as results/p22-debug/debug-results.xml with the heavy save fields;
  6. run exactly five samples and stop.

Inspect one response, assertion, request/response headers and SampleResult fields. Clear the listener after inspection; do not scale this configuration.

7. Record generator memory/CPU during debug

Windows:

Get-CimInstance Win32_Process |
  Where-Object { $_.Name -eq "java.exe" -and $_.CommandLine -like "*ApacheJMeter.jar*" } |
  Select-Object ProcessId, CommandLine

Get-Process -Id <PID> |
  Select-Object Id, CPU, WorkingSet64, PrivateMemorySize64

jcmd <PID> GC.heap_info

Capture a snapshot before the five samples and immediately after. The tiny sample count avoids turning the measurement into a load test; the purpose is simply to observe that GUI/sample-body retention has a measurable state footprint.

8. Inspect heavy artifact

Record:

  • debug-results.xml file size;
  • number of httpSample/responseData nodes;
  • fake-token/email occurrences;
  • JMeter working-set/heap snapshots;
  • target event count = 5;
  • JMeter GUI remains responsive enough for debugging; if not, stop immediately.

9. Define lean CLI result policy

config/lean-results.properties:

# Prompt 22 lean CLI result policy
target.host=127.0.0.1
target.port=8022
threads=2
loops=20
pacing.ms=20
connect.timeout.ms=500
response.timeout.ms=2000

jmeter.save.saveservice.output_format=csv
jmeter.save.saveservice.print_field_names=true
jmeter.save.saveservice.response_data=false
jmeter.save.saveservice.response_data.on_error=false
jmeter.save.saveservice.samplerData=false
jmeter.save.saveservice.responseHeaders=false
jmeter.save.saveservice.requestHeaders=false
jmeter.save.saveservice.url=false
jmeter.save.saveservice.filename=false
jmeter.save.saveservice.hostname=false
jmeter.save.saveservice.encoding=false
jmeter.save.saveservice.bytes=true
jmeter.save.saveservice.sent_bytes=true
jmeter.save.saveservice.thread_counts=true
jmeter.save.saveservice.assertion_results_failure_message=true
jmeter.save.saveservice.autoflush=false

The workload is larger only to make file-size/resource trends easier to observe, but still capped at 40 localhost samples. The JTL keeps timing/status/byte/thread evidence and omits response/request content.

10. Disable heavy GUI listeners before CLI load

Disable View Results Tree in the JMX. You may keep a Summary Report disabled for occasional tiny GUI validation, but do not depend on it for the load path. Use CLI -l as the top-level writer.

11. Run the lean CLI profile

PowerShell:

New-Item -ItemType Directory -Force .\results\p22-lean | Out-Null

& "$env:JMETER_HOME\bin\jmeter.bat" `
  -n `
  -t .\plans\cli-listener-lab.jmx `
  -q .\config\lean-results.properties `
  -Jrun.id=p22-lean `
  -l .\results\p22-lean\results.jtl `
  -j .\results\p22-lean\jmeter.log

Bash:

mkdir -p results/p22-lean
"$JMETER_HOME/bin/jmeter"   -n   -t plans/cli-listener-lab.jmx   -q config/lean-results.properties   -Jrun.id=p22-lean   -l results/p22-lean/results.jtl   -j results/p22-lean/jmeter.log

12. Record lean generator state

Use the same process/heap commands, same machine, and similar sampling timing. Do not change JVM heap/GC or OS priority between debug/lean observations. Because GUI versus CLI differs, treat the memory numbers as evidence of execution/result mode—not a controlled microbenchmark of only one field.

13. Analyze JTL size/content/privacy

Save tools/analyze_artifacts.py:

import csv
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

FAKE_TOKEN = re.compile(r"FAKE-TOKEN-[A-Za-z0-9_.-]+")
FAKE_EMAIL = re.compile(r"[A-Za-z0-9_.-]+@example\.invalid")

def analyze_csv(path):
    rows = list(csv.DictReader(path.open(newline="", encoding="utf-8")))
    failures = sum(r.get("success", "").lower() != "true" for r in rows)
    return {
        "format": "csv",
        "samples": len(rows),
        "failures": failures,
        "response_data_nodes": 0,
        "file_bytes": path.stat().st_size,
    }

def analyze_xml(path):
    samples = failures = response_nodes = 0
    for _, elem in ET.iterparse(path, events=("end",)):
        if elem.tag in {"sample", "httpSample"}:
            samples += 1
            if elem.attrib.get("s", "true").lower() != "true":
                failures += 1
        elif elem.tag == "responseData":
            response_nodes += 1
        elem.clear()
    return {
        "format": "xml",
        "samples": samples,
        "failures": failures,
        "response_data_nodes": response_nodes,
        "file_bytes": path.stat().st_size,
    }

def privacy_hits(path):
    text = path.read_text(encoding="utf-8", errors="replace")
    return {
        "fake_token_hits": len(FAKE_TOKEN.findall(text)),
        "fake_example_invalid_email_hits": len(FAKE_EMAIL.findall(text)),
    }

def analyze(path):
    path = Path(path)
    prefix = path.read_text(encoding="utf-8", errors="replace")[:200].lstrip()
    if prefix.startswith("<?xml") or prefix.startswith("<testResults"):
        result = analyze_xml(path)
    else:
        result = analyze_csv(path)
    result.update(privacy_hits(path))
    return result

if len(sys.argv) < 2:
    raise SystemExit("usage: analyze_artifacts.py <result1.jtl> [result2.jtl ...]")

for raw in sys.argv[1:]:
    p = Path(raw)
    result = analyze(p)
    print(json.dumps({"path": str(p), **result}, sort_keys=True))
python tools/analyze_artifacts.py   results/p22-debug/debug-results.xml   results/p22-lean/results.jtl

Expected:

  • debug XML has 5 samples and response-data nodes;
  • debug XML contains fake token/email markers;
  • lean CSV has 40 samples and zero response-data nodes;
  • lean CSV has zero fake token/email hits because response bodies/headers/sampler data are not retained;
  • the per-sample byte cost of heavy XML is dramatically larger.

14. Add a simple artifact privacy scan

Save tools/privacy_scan.py:

import re
import sys
from pathlib import Path

patterns = {
    "fake_token": re.compile(r"FAKE-TOKEN-[A-Za-z0-9_.-]+"),
    "example_invalid_email": re.compile(r"[A-Za-z0-9_.-]+@example\.invalid"),
    "authorization_bearer": re.compile(r"Authorization:\s*Bearer\s+\S+", re.I),
    "cookie_header": re.compile(r"Cookie:\s*\S+", re.I),
}

failed = False
for raw in sys.argv[1:]:
    path = Path(raw)
    text = path.read_text(encoding="utf-8", errors="replace")
    print(path)
    for name, pattern in patterns.items():
        count = len(pattern.findall(text))
        print(f"  {name}={count}")
        if name in {"authorization_bearer", "cookie_header"} and count:
            failed = True

if failed:
    raise SystemExit(3)
python tools/privacy_scan.py   results/p22-debug/debug-results.xml   results/p22-lean/results.jtl   results/p22-lean/jmeter.log

The fake markers are expected in the heavy XML demonstration. Real Authorization/Cookie header hits should be treated as a failure in real projects; the mandatory lab generates none.

15. Verify target work independently

Save tools/analyze_events.py:

import json
import sys
from collections import Counter
from pathlib import Path

path = Path(sys.argv[1])
run_id = sys.argv[2] if len(sys.argv) > 2 else None
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
work = [e for e in events if e.get("operation") == "work" and (run_id is None or e.get("run_id") == run_id)]

print(f"work_events={len(work)}")
print(f"statuses={dict(Counter(e.get('status') for e in work))}")
print(f"threads={dict(Counter(e.get('thread') for e in work))}")
print(f"max_service_wall_ms={max([int(e.get('service_wall_ms',0)) for e in work] or [0])}")
python tools/analyze_events.py results/server-events.jsonl p22-debug
python tools/analyze_events.py results/server-events.jsonl p22-lean

Expect 5 and 40 work events respectively. For checkpoint equivalence later, both variants will use the same sample count.

16. Where Simple Data Writer fits

If a specific controller/sampler subset needs a scoped file, Simple Data Writer is the low-GUI-overhead JMX listener choice. It writes selected result fields to a file and does not render UI results. For a whole CLI run, prefer -l to avoid duplicate writers.

17. Where Summary Report fits

Summary Report keeps aggregate rows and uses less memory than detailed views. It is useful for a tiny interactive validation. In CLI load, use the built-in CLI summariser/console plus raw JTL rather than adding GUI reports to make the test “observable.”

18. Challenge

You need to debug one failing authorization assertion, then run 100,000 samples. Should you keep response bodies and Authorization headers for the whole run?

No. Use a tiny authorized debug reproduction with focused response/header inspection, then restore metadata-only load retention. If failure context must be captured during load, prefer narrowly scoped failure-only evidence with explicit privacy controls rather than every body/header.

Knowledge check

Why do debug XML and lean CSV intentionally use different sample counts in Lesson 2?

Why is a fake-token hit expected in debug XML?

Why should lean CSV have zero fake-token/email hits?

What does Simple Data Writer add compared with -l?

Why keep jmeter.log even when JTL looks healthy?

Next lesson

Choose the right result consumer for each phase

Lesson 3 turns the workflow into phase-specific trade-offs: GUI debug versus file writer, bodies versus metadata, CSV versus XML, per-sample JTL versus backend telemetry, and aggregate console evidence versus forensic artifacts.

Official references and version notes

Version and compatibility note

Version-sensitive statements were rechecked against current Apache JMeter primary documentation on 2026-09-05. The course baseline remains Apache JMeter 5.6.3 with a Java 17 JDK; JMeter 5.6.3 requires Java 8+. Current documentation says View Results Tree MUST NOT BE USED during load testing because it consumes substantial memory and CPU; it is intended for functional/debug/validation work. Its displayed entry count defaults to 500 via view.results.tree.max_results; response display is also bounded by view.results.tree.max_size (200K default in current documentation), but response data can still exist in the SampleResult. Listeners can consume large amounts of memory because many keep samples they display. The documented low-memory choices include Simple Data Writer and Summary Report; Aggregate Report/Graph aggregate samples rather than retaining every individual sample. JMeter explicitly recommends Simple Data Writer plus CSV to minimize memory. The CLI -l listener is controlled by save-service properties. Current result defaults use CSV; response_data=false, samplerData=false, request/response headers false, bytes true, sent bytes true, thread counts true, and CSV field names true. Response data cannot be stored in CSV; XML can store text response data but can become very large. jmeter.save.saveservice.autoflush=true can reduce result loss on crash but has a performance cost, particularly in intensive tests, and is false by default.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.