Chapter 20Lesson 02~255 minutes

Functions, Custom Properties, Reusable Fragments, and Modular Plans: Guided Hands-On Workflow

The lab starts from a duplicated setup sequence and progressively moves configuration and setup logic into explicit reusable boundaries. The final Include variant contains no machine-specific path in the JMX: a project launcher resolves the fragment prefix and records it in a command manifest before JMeter starts.

Project tree__P functionsModule/IncludeProperty filePortable launcher

Learning objectives

  • Create a disposable localhost session/work fixture.
  • Parameterize host/port/threads/loops/pacing through an external property file and __P.
  • Extract a session bootstrap flow into a Test Fragment and call it with Module Controller.
  • Move the fragment to an external JMX and call it with Include Controller.
  • Resolve Include paths with an absolute project-root prefix supplied by a launcher.
  • Run the same plan from two unrelated working directories and compare command/path/target evidence.

1. Hard safety envelope

Only http://127.0.0.1:8020. Maximum 2 threads, exactly 3 work loops per thread in the mandatory profile, 25 ms Constant Timer, ≤10 seconds/run, synthetic user/session identifiers only, no credentials, no plugins/containers/remote engines. Abort on non-loopback resolved host, >2 threads, >3 loops, active sessions left after run, duplicate/cross-thread SESSION_ID use, include errors, or sustained generator saturation.

2. Project tree

p20-modular-lab/
├── config/
│   └── local.properties
├── fixtures/
│   └── modular_fixture.py
├── fragments/
│   └── session-bootstrap.jmx
├── plans/
│   ├── main-module.jmx
│   └── main-include.jmx
├── tools/
│   ├── run-local.ps1
│   ├── run-local.sh
│   ├── analyze_events.py
│   └── check_manifests.py
└── results/

The five Chapter HTML files remain the only ZIP contents; these lab files are shown as code/configuration learners create locally.

3. Create the local fixture

Save fixtures/modular_fixture.py:

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

FIXTURE_VERSION = "prompt20-modular-fixture-v1"
SAFE_USER = re.compile(r"^user-[1-9][0-9]*$")
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")

lock = threading.Lock()
event_log = None
session_seq = itertools.count(1)
sessions = {}
metrics = {
    "requests": 0,
    "errors": 0,
    "sessions_created": 0,
    "sessions_deleted": 0,
    "work_requests": 0,
    "by_run": {},
    "by_variant": {},
}

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

def make_session_id(run_id, user, seq):
    raw = f"{run_id}|{user}|{seq}".encode("utf-8")
    return "S-" + hashlib.sha256(raw).hexdigest()[:16]

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 send_empty(self, status):
        self.send_response(status)
        self.send_header("Content-Length", "0")
        self.send_header("X-Fixture-Version", FIXTURE_VERSION)
        self.end_headers()

    def record(self, started, operation, status, **extra):
        with lock:
            metrics["requests"] += 1
            if status >= 400:
                metrics["errors"] += 1
            run_id = extra.get("run_id", "")
            variant = extra.get("variant", "")
            if run_id:
                metrics["by_run"][run_id] = metrics["by_run"].get(run_id, 0) + 1
            if variant:
                metrics["by_variant"][variant] = metrics["by_variant"].get(variant, 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)
        q = parse_qs(parsed.query)

        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))
                active = [
                    {
                        "session_id": sid,
                        "user": item["user"],
                        "run_id": item["run_id"],
                        "variant": item["variant"],
                    }
                    for sid, item in sorted(sessions.items())
                ]
            self.send_json(200, {
                "fixture_version": FIXTURE_VERSION,
                "active_sessions": len(active),
                "sessions": active,
                "metrics": snapshot,
            })
            self.record(started, "stats", 200)
            return

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

        sid = q.get("session_id", [""])[0]
        thread_id = q.get("thread", [""])[0]
        seq_raw = q.get("seq", [""])[0]
        run_id = q.get("run_id", [""])[0]
        variant = q.get("variant", [""])[0]

        if not SAFE_TOKEN.fullmatch(sid) or not SAFE_TOKEN.fullmatch(thread_id):
            self.send_json(400, {"status": "invalid_metadata"})
            self.record(started, "work", 400, run_id=run_id, variant=variant)
            return
        if not SAFE_TOKEN.fullmatch(run_id) or not SAFE_TOKEN.fullmatch(variant):
            self.send_json(400, {"status": "invalid_run"})
            self.record(started, "work", 400, run_id=run_id, variant=variant)
            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, variant=variant)
            return

        with lock:
            session = dict(sessions.get(sid, {}))
        if not session:
            self.send_json(401, {"status": "unknown_session"})
            self.record(started, "work", 401, run_id=run_id, variant=variant, session_id=sid)
            return
        if session["run_id"] != run_id or session["variant"] != variant:
            self.send_json(409, {"status": "session_scope_mismatch"})
            self.record(started, "work", 409, run_id=run_id, variant=variant, session_id=sid)
            return

        with lock:
            metrics["work_requests"] += 1
        payload = {
            "status": "ok",
            "session_id": sid,
            "user": session["user"],
            "thread": thread_id,
            "seq": seq,
            "run_id": run_id,
            "variant": variant,
        }
        self.send_json(200, payload)
        self.record(
            started, "work", 200,
            run_id=run_id, variant=variant, session_id=sid,
            user=session["user"], thread=thread_id, seq=seq,
        )

    def do_POST(self):
        started = now_ms()
        parsed = urlparse(self.path)
        q = parse_qs(parsed.query)

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

        user = q.get("user", [""])[0]
        run_id = q.get("run_id", [""])[0]
        variant = q.get("variant", [""])[0]
        if not SAFE_USER.fullmatch(user) or not SAFE_TOKEN.fullmatch(run_id) or not SAFE_TOKEN.fullmatch(variant):
            self.send_json(400, {"status": "invalid_session_input"})
            self.record(started, "session_create", 400, run_id=run_id, variant=variant, user=user)
            return

        seq = next(session_seq)
        sid = make_session_id(run_id, user, seq)
        with lock:
            sessions[sid] = {"user": user, "run_id": run_id, "variant": variant}
            metrics["sessions_created"] += 1

        self.send_json(201, {
            "status": "created",
            "session_id": sid,
            "user": user,
            "run_id": run_id,
            "variant": variant,
        })
        self.record(
            started, "session_create", 201,
            run_id=run_id, variant=variant, user=user, session_id=sid,
        )

    def do_DELETE(self):
        started = now_ms()
        parsed = urlparse(self.path)
        if not parsed.path.startswith("/session/"):
            self.send_json(404, {"status": "not_found"})
            self.record(started, "unknown_delete", 404)
            return

        sid = parsed.path.split("/", 2)[2]
        with lock:
            item = sessions.pop(sid, None)
            if item:
                metrics["sessions_deleted"] += 1
        if not item:
            self.send_json(404, {"status": "unknown_session", "session_id": sid})
            self.record(started, "session_delete", 404, session_id=sid)
            return

        self.send_empty(204)
        self.record(
            started, "session_delete", 204,
            run_id=item["run_id"], variant=item["variant"],
            user=item["user"], session_id=sid,
        )

    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=8020)
    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 it:

python .\fixtures\modular_fixture.py `
  --host 127.0.0.1 `
  --port 8020 `
  --log .\results\server-events.jsonl

Bash uses the same flags. The service creates run/user-scoped synthetic sessions, rejects mismatched session/run/variant combinations, records work requests, and supports exact per-session deletion.

4. Read-only target preflight

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

Require fixture version prompt20-modular-fixture-v1 and active_sessions=0 before each measured run.

5. Externalize local environment/load properties

Save config/local.properties:

# Prompt 20 mandatory localhost environment
target.host=127.0.0.1
target.port=8020
threads=2
loops=3
pacing.ms=25
connect.timeout.ms=500
response.timeout.ms=2000

These properties are not user/session state. They describe the environment and configured load. JMeter reads them with -q, and plan fields use explicit fallbacks such as:

Host: ${__P(target.host,127.0.0.1)}
Port: ${__P(target.port,8020)}
Threads: ${__P(threads,1)}
Loop Controller loops: ${__P(loops,1)}
Constant Timer: ${__P(pacing.ms,25)}
Connect timeout: ${__P(connect.timeout.ms,500)}
Response timeout: ${__P(response.timeout.ms,2000)}

6. Selected functions with clear ownership

Function Where used State/effect
${{__P(run.id,p20-default)}} HTTP query fields Reads immutable JVM run property.
${{__P(variant,module)}} HTTP query fields Labels target evidence by plan variant.
${{__threadNum}} Session-bootstrap sampler field Current Thread Group thread number; produces user-1/user-2.
${{__property(user.dir,JMETER_USER_DIR)}} One-thread debug/preflight only Reads generator working-directory system property and saves to a variable.
${{__UUID()}} Optional trace/debug only Unique pseudo-random ID; not used for replay-sensitive business data.

Do not put __threadNum inside User Defined Variables. Use it where a sampler is executing in the JMeter thread.

7. Start from the duplicated setup problem

Suppose two scenario branches each contain:

POST /session?user=user-${__threadNum}&run_id=${__P(run.id)}&variant=${__P(variant)}
├── JSON JMESPath Assertion: status == created
├── JSON JMESPath Extractor: SESSION_ID <- session_id
└── Response Assertion: SESSION_ID != __NOT_FOUND__

The definitions are duplicated even though runtime needs the same behavior. Refactor definitions without changing when the setup executes.

8. Same-JMX refactor with Test Fragment + Module Controller

In plans/main-module.jmx:

Test Plan
├── HTTP Request Defaults (property-backed)
├── Test Fragment — FRAG.session.bootstrap.v1
│   └── POST Session Create
│       ├── JSON JMESPath Assertion status == created
│       ├── JMESPath Extractor SESSION_ID <- session_id
│       └── Response Assertion SESSION_ID exists
└── Thread Group
    ├── Once Only Controller
    │   └── Module Controller -> FRAG.session.bootstrap.v1
    ├── Counter -> SEQ (per user)
    ├── Loop Controller loops=${__P(loops,1)}
    │   └── GET Work
    │       ├── Constant Timer ${__P(pacing.ms,25)} ms
    │       ├── JMESPath Assertion status == ok
    │       └── Response Assertion returned session_id == ${SESSION_ID}
    └── DELETE Session ${SESSION_ID}
        └── assert 204

The fragment is defined once and the Module Controller reference executes it once per thread because it sits under Once Only Controller.

9. Extract the fragment to an external JMX

Create fragments/session-bootstrap.jmx containing one Test Plan with a Test Fragment named FRAG.session.bootstrap.v1 and the Session Create sampler/assertions/extractor. An optional debug Thread Group may exist in that file, but it is ignored when included.

Do not put HTTP Cookie Manager/User Defined Variables in the external fragment; keep shared configuration in the top-level main plan.

10. External reuse with Include Controller

In plans/main-include.jmx, replace the Module Controller with:

Once Only Controller
└── Include Controller — INC.session.bootstrap.v1
    Filename: session-bootstrap.jmx

The Filename is intentionally plain and stable because Include Controller does not support functions/variables there. The path comes from includecontroller.prefix supplied by the launcher.

11. Windows PowerShell project launcher

Save tools/run-local.ps1:

param(
  [ValidateSet("module","include")]
  [string]$Variant = "include",
  [string]$RunId = "p20-local"
)

$ErrorActionPreference = "Stop"
if (-not $env:JMETER_HOME) {
  throw "JMETER_HOME must point to Apache JMeter 5.6.3."
}

$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$ProjectRootUnix = ($ProjectRoot -replace "\\","/")
$Props = Join-Path $ProjectRoot "config\local.properties"
$Jmx = Join-Path $ProjectRoot ("plans\main-{0}.jmx" -f $Variant)
$ResultDir = Join-Path $ProjectRoot ("results\{0}" -f $RunId)
New-Item -ItemType Directory -Force $ResultDir | Out-Null

# Include Controller filename is fixed as session-bootstrap.jmx.
# Resolve its prefix outside the JMX so the same plan works from any cwd.
$IncludePrefix = "$ProjectRootUnix/fragments/"
$Jtl = Join-Path $ResultDir "results.jtl"
$Log = Join-Path $ResultDir "jmeter.log"
$Manifest = Join-Path $ResultDir "command-manifest.json"

[ordered]@{
  cwd = (Get-Location).Path
  project_root = $ProjectRoot
  jmx = $Jmx
  property_file = $Props
  include_prefix = $IncludePrefix
  run_id = $RunId
  variant = $Variant
  result_jtl = $Jtl
  jmeter_log = $Log
} | ConvertTo-Json | Set-Content -Encoding UTF8 $Manifest

& "$env:JMETER_HOME\bin\jmeter.bat" `
  -n `
  -t "$Jmx" `
  -q "$Props" `
  -Jrun.id="$RunId" `
  -Jvariant="$Variant" `
  -Jincludecontroller.prefix="$IncludePrefix" `
  -l "$Jtl" `
  -j "$Log"

exit $LASTEXITCODE

The launcher reads its own location ($PSScriptRoot) rather than trusting the caller's working directory. It records cwd, project root, JMX, property file, include prefix and output paths before starting JMeter.

12. Bash project launcher

Save tools/run-local.sh:

#!/usr/bin/env bash
set -euo pipefail

VARIANT="${1:-include}"
RUN_ID="${2:-p20-local}"
case "$VARIANT" in
  module|include) ;;
  *) echo "Variant must be module or include" >&2; exit 2 ;;
esac

: "${JMETER_HOME:?JMETER_HOME must point to Apache JMeter 5.6.3}"

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"
PROPS="$PROJECT_ROOT/config/local.properties"
JMX="$PROJECT_ROOT/plans/main-$VARIANT.jmx"
RESULT_DIR="$PROJECT_ROOT/results/$RUN_ID"
mkdir -p "$RESULT_DIR"

INCLUDE_PREFIX="$PROJECT_ROOT/fragments/"
JTL="$RESULT_DIR/results.jtl"
LOG="$RESULT_DIR/jmeter.log"
MANIFEST="$RESULT_DIR/command-manifest.json"

python - "$MANIFEST" "$PWD" "$PROJECT_ROOT" "$JMX" "$PROPS" "$INCLUDE_PREFIX" "$RUN_ID" "$VARIANT" "$JTL" "$LOG" <<'PY'
import json, sys
keys = ["manifest","cwd","project_root","jmx","property_file","include_prefix","run_id","variant","result_jtl","jmeter_log"]
d = dict(zip(keys, sys.argv[1:]))
manifest = d.pop("manifest")
with open(manifest, "w", encoding="utf-8") as f:
    json.dump(d, f, indent=2)
PY

"$JMETER_HOME/bin/jmeter" \
  -n \
  -t "$JMX" \
  -q "$PROPS" \
  -Jrun.id="$RUN_ID" \
  -Jvariant="$VARIANT" \
  -Jincludecontroller.prefix="$INCLUDE_PREFIX" \
  -l "$JTL" \
  -j "$LOG"

On Linux/macOS mark it executable with chmod +x tools/run-local.sh. The same design resolves the project root from the launcher file rather than $PWD.

13. Prove portability from two working directories

PowerShell — run once from project root:

Set-Location "F:\Labs\p20-modular-lab"
.\tools\run-local.ps1 -Variant include -RunId p20-root

Then from an unrelated directory:

Set-Location "$env:TEMP"
& "F:\Labs\p20-modular-lab\tools\run-local.ps1" `
  -Variant include `
  -RunId p20-temp

Expected: the two command manifests have different cwd values but the same resolved project_root/include_prefix; both target runs produce two sessions, six work requests, two deletes, and zero active sessions afterward.

14. Compare same-plan Module variant

Run:

.\tools\run-local.ps1 -Variant module -RunId p20-module

Module variant does not require the external fragment path to execute its same-JMX fragment, but the launcher still records the include prefix for a common manifest schema. Target behavior should match the Include variant.

15. Analyze target evidence

Save tools/analyze_events.py:

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

path = Path(sys.argv[1])
events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
interesting = [e for e in events if e.get("operation") in {"session_create", "work", "session_delete"}]

print(f"events={len(events)}")
print(f"interesting_events={len(interesting)}")
print(f"operations={dict(Counter(e.get('operation') for e in interesting))}")
print(f"statuses={dict(Counter(e.get('status') for e in interesting))}")
print(f"runs={dict(Counter(e.get('run_id') for e in interesting if e.get('run_id')))}")
print(f"variants={dict(Counter(e.get('variant') for e in interesting if e.get('variant')))}")
print(f"users={dict(Counter(e.get('user') for e in interesting if e.get('user')))}")
print(f"work_by_thread={dict(Counter(e.get('thread') for e in interesting if e.get('operation') == 'work'))}")

created = {e["session_id"] for e in interesting if e.get("operation") == "session_create" and e.get("status") == 201}
deleted = {e["session_id"] for e in interesting if e.get("operation") == "session_delete" and e.get("status") == 204}
print(f"created_sessions={len(created)}")
print(f"deleted_sessions={len(deleted)}")
print(f"undeleted_sessions={len(created - deleted)}")
print(f"max_service_wall_ms={max([int(e.get('service_wall_ms',0)) for e in interesting] or [0])}")
python tools/analyze_events.py results/server-events.jsonl

For each clean mandatory run, expect 2 session creates, 6 work requests, 2 deletes, zero undeleted sessions, and only 2xx statuses.

16. Verify the two-run command manifests

Save tools/check_manifests.py:

import json
import sys
from pathlib import Path

paths = [Path(p) for p in sys.argv[1:]]
for path in paths:
    doc = json.loads(path.read_text(encoding="utf-8"))
    required = {
        "cwd", "project_root", "jmx", "property_file",
        "include_prefix", "run_id", "variant", "result_jtl", "jmeter_log"
    }
    missing = required - set(doc)
    if missing:
        raise SystemExit(f"{path}: missing keys {sorted(missing)}")
    print(
        f"{path}: cwd={doc['cwd']} project_root={doc['project_root']} "
        f"jmx={doc['jmx']} include_prefix={doc['include_prefix']} "
        f"run_id={doc['run_id']} variant={doc['variant']}"
    )
python tools/check_manifests.py   results/p20-root/command-manifest.json   results/p20-temp/command-manifest.json

The different cwd values are intentional; resolved project root, JMX, properties and include prefix must point to the same project assets.

17. Duplication-reduction note

Before refactoring, two scenarios each owned their own four-element bootstrap definition: eight setup definitions to review. After externalization, the four-element bootstrap exists once plus lightweight controller references. Runtime still executes one bootstrap per thread; only definition duplication is reduced. Record that distinction in code review.

18. Challenge

Three top-level plans use the same session bootstrap, but one plan needs a different response assertion. Should the fragment receive dozens of flags?

Keep the stable common bootstrap in the fragment and place scenario-specific assertions near the calling plan when practical. If conditional flags make the fragment's behavior opaque, split it into clearly named modules instead of building a hidden framework inside properties/functions.

Knowledge check

Why does the launcher set includecontroller.prefix instead of putting ${__P(fragment.path)} in Include Filename?

Why is setup under Once Only Controller?

What should differ between the root-cwd and temp-cwd command manifests?

What does the duplication note measure?

Why is __UUID not used as replay-sensitive test data here?

Next lesson

Choose modular boundaries deliberately

Lesson 3 compares functions/variables/properties, Module/Include, one versus multiple JMX files, path strategies, and deterministic versus random/generated data.

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+. A Module Controller substitutes an already loaded controller/fragment into the active runtime path; fragments referenced by Module Controller need unique names because JMeter uses the controller/parent name path to find them after reload. Include Controller is for external JMX/Test Fragment content. Its Filename field does not support variables/functions. The includecontroller.prefix property can prefix the filename; if prefix+filename cannot be found, JMeter attempts the filename relative to the JMX launch directory. Top-level Cookie Manager/User Defined Variables belong in the main test plan rather than an included JMX because included copies are not guaranteed to work as expected. __P(name,default) is intended for command-line properties; without an explicit default it returns 1. __threadNum is local to its Thread Group and should not be placed in Configuration Elements such as User Defined Variables because those are processed by a separate thread. For reproducible random numeric data, the Random Variable Config Element exposes a seed/per-thread option; the simple __Random function does not expose an equivalent seed parameter.

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.