Bash Xtrace Routing: Capture Debug Traces Without Mixing stderr
Route Bash set -x output to a private file descriptor, understand descriptor ownership and trace timing, and avoid leaking expanded secrets into logs.
Bash’s set -x prints commands as the shell executes them. That is useful for debugging control flow, but its trace stream normally shares standard error with warnings and application diagnostics. When a script’s stderr is collected as structured output, a trace line can corrupt the format; when multiple processes write there, the trace can be difficult to attribute.
The BASH_XTRACEFD variable lets Bash send xtrace output to a chosen open file descriptor instead. It is a routing mechanism, not a redaction feature or a complete logging system. A useful setup keeps the trace private, gives lines enough context to locate a command, scopes tracing to the failing region, and closes the descriptor without disturbing stderr.
Open a dedicated descriptor before enabling tracing
Use a securely created file and a descriptor other than 2. This example sets a restrictive umask before mktemp, opens the file for append on descriptor 3, and tells Bash to use that descriptor:
umask 077
trace_file=$(mktemp "${TMPDIR:-/tmp}/worker-xtrace.XXXXXX") || exit 1
exec 3>>"$trace_file" || exit 1
BASH_XTRACEFD=3
PS4='+ ${BASH_SOURCE[0]}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
do_the_work "$input_path"
set +x
The path is created uniquely rather than relying on a predictable name, and umask 077 asks the system to make new files private to the current user. Check the behavior of the platform’s mktemp implementation and filesystem policy when the trace may contain sensitive operational data. A file’s permissions do not protect it from the same account, a privileged administrator, backups, or centralized log ingestion.
PS4 is expanded to prefix trace lines. Source file, line number, and function context make nested shell logic easier to follow. For scripts that source multiple files, ${BASH_SOURCE[0]} identifies the current source frame. Treat any extra command substitution placed in PS4 cautiously: prompt-style expansion work is performed while tracing and can add cost or side effects.
Understand what a trace line contains
Xtrace is generated after Bash expands a simple command’s words and before it executes the command. It is therefore much more informative than a literal copy of the source line, but that also means arguments can appear in expanded form. Passwords passed as command-line arguments, tokens embedded in a URL, decrypted values, and private file paths may be written to the trace.
Do not assume that quoting hides a value from xtrace. Quoting affects how Bash parses and expands the word; it does not make the expanded argument secret in the trace. Do not enable tracing around credential retrieval, signing operations, or commands that receive secrets unless the trace sink and retention policy are explicitly approved for that data.
If only one region is under investigation, keep the trace window narrow:
set -x
prepare_request "$endpoint"
send_request
set +x
read_secret_from_agent
If an existing script may inherit -x from its caller or environment, explicitly decide whether to preserve that state. Blindly running set +x at a library boundary can disable the caller’s diagnostics. For a top-level diagnostic run, launching a fresh Bash process with bash -x script.sh is often clearer than changing tracing state inside every function.
File-descriptor lifetime is part of the design
BASH_XTRACEFD must name an already-open descriptor. Assigning a new value closes the descriptor Bash was using; unsetting the variable also closes that descriptor and returns trace output to standard error. The Bash manual specifically warns that using descriptor 2 and then unsetting BASH_XTRACEFD closes stderr. Use a dedicated descriptor such as 3, and do not repoint the variable casually in shared shell code.
Check the Bash version before using this interface. BASH_XTRACEFD was added in Bash 4.1; older releases such as the Bash 3.2 shipped by some operating systems do not implement it. On those shells the assignment is only an ordinary variable assignment, so set -x continues writing to stderr. If you must support an older interpreter, redirect the shell’s stderr around the diagnostic run or use a different debugging setup rather than assuming this variable is active.
Close tracing and release the descriptor deliberately:
set +x
unset BASH_XTRACEFD # Closes the descriptor selected above and returns xtrace to stderr.
That example is appropriate when this script owns the variable and descriptor. A function library should not unset a descriptor it did not create. If cleanup runs through traps, preserve the exit status and avoid emitting additional traced commands while the trace is being shut down. A common pattern is to disable xtrace first, save the status before cleanup, then perform cleanup and exit with the saved status.
The descriptor is inherited by child processes unless it is closed or marked close-on-exec by a lower-level mechanism. Bash redirection syntax alone does not establish a general secrecy boundary between a shell and its descendants. Consider whether children can keep the file open, write interleaved content, or expose the descriptor to tools that do not need it. Keep descriptor ownership explicit.
Separate trace data from diagnostic data
Routing xtrace away from stderr preserves the channel used by ordinary diagnostics. It does not make xtrace structured JSON, guarantee one complete line per high-level operation, or coordinate records from concurrent processes. Add a stable prefix with PS4, but do not parse shell traces as a durable machine interface. Bash syntax, quoting, and nested expansion make that fragile.
Choose a log destination with the same care as any other operational log. Decide who may read it, how long it remains, how it is rotated, whether it is shipped elsewhere, and how it is removed after an incident. A long-running trace can grow quickly; a set -x left enabled around a loop may turn a small failure into a large log or expose every item processed.
If the goal is to debug a value, print a deliberately sanitized representation at the point of interest instead of tracing the entire script. If the goal is production observability, use explicit structured events with stable fields rather than exposing every expanded command. Xtrace is best treated as a short-lived diagnostic artifact.
Verify the routing with a harmless probe
Before investigating a real job, validate that trace output goes to the intended file while stderr remains separate:
printf '%s\n' 'ordinary diagnostic' >&2
set -x
printf '%s\n' 'trace probe'
set +x
The ordinary diagnostic should remain on stderr, and the printf command trace should be in the file associated with descriptor 3. Remove the probe output and test cleanup in a disposable directory. Do not test the mechanism with production credentials or copy a real trace into a ticket before reviewing it for sensitive expansions.
Use xtrace to answer a bounded question
BASH_XTRACEFD is valuable when stderr must stay usable and an engineer needs to inspect shell evaluation. The operational discipline matters as much as the variable: open a private sink, choose a non-stderr descriptor, scope tracing, add useful source context, review for secrets, and close the descriptor you own.
It is not a substitute for tests, application logs, or a security review. Because Bash writes expanded command details, the safest trace is the narrowest one that answers a specific question and is deleted or retained under an intentional policy.
Related:
- Shell File Descriptors and Redirection: Ordering, Duplication, and Lifetime
- How to Debug Shell Scripts With set -x and ShellCheck
Sources: