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.
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.logseparately in every CLI run.
1. Safety envelope
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:
- load
cli-listener-lab.jmx; -
load/apply the heavy debug property settings before startup (for
example through
-qwhen launching the GUI or matching listener Save Configuration); - set
run.id=p22-debug, 1 thread, 5 loops; - enable View Results Tree;
-
configure the debug result collector/file as
results/p22-debug/debug-results.xmlwith the heavy save fields; - 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.xmlfile size;-
number of
httpSample/responseDatanodes; - 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?
The first is a tiny forensic GUI exercise; the second is still-bounded CLI load evidence. The checkpoint later uses identical counts for a cleaner result-policy comparison.
Why is a fake-token hit expected in debug XML?
Response data is intentionally retained and the synthetic fixture places FAKE-TOKEN markers in its body.
Why should lean CSV have zero fake-token/email hits?
Those values exist only in the response body, which the lean policy does not retain.
What does Simple Data Writer add compared with -l?
It can write a scoped subset from inside the JMX; -l is simpler for one top-level whole-run file.
Why keep jmeter.log even when JTL looks healthy?
Engine/listener/script/configuration warnings/errors may not be represented as ordinary sample failures.
Official references and version notes
-
JMeter User Manual — Listeners
— result-file formats, save-service fields, CLI
-l, memory guidance, CSV/XML capabilities, response-data cost, and sample variables. - Component Reference — View Results Tree — explicit warning against load-test use, entry/response display limits, and debug behavior.
- Component Reference — Simple Data Writer — efficient file output without GUI rendering.
- Component Reference — Summary Report — aggregate summary behavior and lower memory use than detailed per-sample GUI views.
-
JMeter Properties Reference
— current
jmeter.save.saveservice.*defaults, auto-flush trade-off, CLI summariser, and result configuration. - JMeter Best Practices — CLI load mode, few listeners, no View Results Tree/Table during load, CSV, and only required fields.
- Apache JMeter downloads — current stable release and Java requirement.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.