Bash Output Verbose Logs While Still Capturing Output

I was recently working in Bash (a language I'm familiar with but don't develop in often) and I needed to capture the output of bash command and save it to a variable. The standard way to do this is to use the $() syntax to run the command in a subshell and return the value.

So for example, if we wanted to run the function add and capture the output to the variable value, we would write:

function add() {
  echo $(($1 + $2))
}

value=$(add 5 3)
echo $value  # 8

This worked great until I wanted to add verbose log output to the function I was calling. This proved a difficult challenge since adding additional output would be saved to the variable, which is not what I wanted.

verbose=1

function add() {
  result=$(($1 + $2))
  if [[ "$verbose" -eq 1 ]]; then
    echo "adding $1 and $2 for a result of $result"
  fi

  echo $result
}

value=$(add 5 3)
echo $value  # adding 5 and 3 for a result of 8 8

I tried a lot of different ideas on how to solve the problem, from taking the last line of the output to using the tee command. Everything I tried never really worked the way I wanted. Either the logs would display everything (including the last line which wasn't necessary) or output in the wrong order (especially with error messages sending to stderr).

Eventually I stumbled upon this stackoverflow question which let me do exactly what I wanted.

The solution is use a new file descriptor to send the output of the verbose log to something other than stdout. However, using a new file descriptor will result in an error if the calling script doesn't define how to use it. Normally you would do this when you call the command. However, sending all the output from the new file descriptor to stdout would get us back to square one and all the logs would still end up in the captured variable.

function add() {
  result=$(($1 + $2))
  if [[ "$verbose" -eq 1 ]]; then
    # Send output to file descriptor 3
    >&3 echo "adding $1 and $2 for a result of $result"
  fi

  echo $result
}

# Notice that file descriptor 3 is being redirected to stdout (file descriptor 1)
value=$(add 5 3 3>&1)
echo $value  # adding 5 and 3 for a result of 8 8

Instead what we need to do is use the exec command to tell bash to redirect all output from the new file descriptor to stdout for all commands. Doing this allows us to output the verbose logs to stdout without adding them to the captured variable.

exec 3>&1
verbose=1

function add() {
  result=$(($1 + $2))
  if [[ "$verbose" -eq 1 ]]; then
    # Send output to file descriptor 3
    >&3 echo "adding $1 and $2 for a result of $result"
  fi

  echo $result
}

value=$(add 5 3)
echo $value  # 8

Running this would produce the desired result:

$ sh script.sh
adding 5 and 3 for a result of 8
8