Bazel Knowledge: Diagnostic messages only on failure

Published 2025-06-10 on Farid Zakaria's Blog

I have been writing quite a few Bazel rules recently, and I’ve been frustrated with the fact that STDOUT and STDERR are emitted always for rules that are run even when the actions are successful. 😩

I like to audit our build logs for warnings and spurious noise. A happy build should ideally be a quiet build. 🤫

The inability of ctx.actions.run or ctx.actions.run_shell to suppress output on successful builds is a longstanding gap that seems to have been re-implemented by many independent codebases and rules such as in rules_js#js_binary.

There has been a longstanding feature request to also support automatically capturing output for ctx.actions.run without having to resort to ctx.actions.run_shell needlessly #5511.

Do want to join the cabal of quiet builds? 🧘‍♂️

Here is the simplest way to achieve that!

Let’s write our simple wrapper that will invoke any program but capture the output.

#!/usr/bin/env bash
set -o pipefail -o nounset

if [ "$#" -lt 3 ]; then
  echo "Usage: $0 <stdout-file> <stderr-file> <command> [args...]" >&2
  exit 1
fi

STDOUT_FILE="$1"
STDERR_FILE="$2"
shift 2

"$@" >"$STDOUT_FILE" 2>"$STDERR_FILE"
STATUS=$?
 
if [ "$STATUS" -ne 0 ]; then
  echo "--- Command Failed ---" >&2
  echo "Status: $STATUS" >&2
  echo "Command: $*" >&2
  echo "--- STDOUT ---" >&2
  cat "$STDOUT_FILE" >&2
  echo "--- STDERR ---" >&2
  cat "$STDERR_FILE" >&2
  echo "--- End of Output ---" >&2
  exit "$STATUS"
fi

We will create a simple sh_binary to wrap this script. Nothing fancy.

load("@rules_shell//shell:sh_binary.bzl", "sh_binary")

sh_binary(
    name = "quiet_runner",
    srcs = ["quiet_runner.sh"],
    visibility = ["//visibility:public"],
)

Now, when it’s time to leverage this rule, we make sure to provide it as the executable for ctx.actions.run.

I also like to provide the STDOUT & STDERR as an output group so they can easily be queried and investigated even on successful builds.

Let’s write a simple rule to demonstrate.

Let’s start off with our tool we want to leverage in our rule. This tool simply emits “hello world” to STDOUT, STDERR and a provided file.

import java.io.FileWriter;
import java.io.IOException;

public class HelloWorld {
  public static void main(String[] args) {
    if (args.length < 1) {
      System.err.println("Please provide a filename as the first argument.");
      return;
    }
    String filename = args[0];
    String message = "hello world";
    System.out.println(message);
    System.err.println(message);
    try (FileWriter writer = new FileWriter(filename, true)) {
      writer.write(message + System.lineSeparator());
    } catch (IOException e) {
      System.err.println("Failed to write to file: " + e.getMessage());
    }
  }
}

We now write our rule to leverage the tool.

The important parts to notice are:

  • We must provide the actual tool we want to run (i.e. HelloWorld) as a tool in tools so it is present as a runfile.
  • We include the stdout and stderr as an OutputGroupInfo.
  • Our executable is our quiet runner that we created earlier.
def _hello_world_impl(ctx):
    output = ctx.actions.declare_file("{}.txt".format(ctx.label.name))
    stdout = ctx.actions.declare_file("{}.out.log".format(ctx.label.name))
    stderr = ctx.actions.declare_file("{}.err.log".format(ctx.label.name))

    ctx.actions.run(
        outputs = [output, stdout, stderr],
        executable = ctx.executable._runner,
        arguments = [
            stdout.path,
            stderr.path,
            ctx.executable._binary.path,
            output.path,
        ],
        tools = [
            ctx.executable._binary,
        ],
    )

    return [
        DefaultInfo(files = depset(direct = [output])),
        OutputGroupInfo(
            output = depset([stderr, stdout]),
        ),
    ]

hello_world = rule(
    implementation = _hello_world_impl,
    attrs = {
        "_binary": attr.label(
            default = Label("//:HelloWorld"),
            executable = True,
            cfg = "exec",
        ),
        "_runner": attr.label(
            default = Label("//:quiet_runner"),
            executable = True,
            cfg = "exec",
        ),
    },
)

When we have a successful build, it is quiet. 😌

> bazel build //:hello_world
INFO: Invocation ID: 114e65ff-a263-4dcd-9b4f-de6cef10d36a
INFO: Analyzed target //:hello_world (1 packages loaded, 5 targets configured).
INFO: Found 1 target...
Target //:hello_world up-to-date:
  bazel-bin/hello_world.txt

If I were to induce a failure in our tool, by having it return System.exit(-1) we can see the logs now include the relevant information.

> bazel build //:hello_world
INFO: Invocation ID: fb1170c9-7f38-4269-9d60-7d03155837c2
INFO: Analyzed target //:hello_world (0 packages loaded, 0 targets configured).
ERROR: BUILD.bazel:15:12: Action hello_world.txt failed: (Exit 255): quiet_runner failed: error executing Action command (from target //:hello_world) bazel-out/darwin_arm64-opt-exec-ST-d57f47055a04/bin/quiet_runner bazel-out/darwin_arm64-fastbuild/bin/hello_world.out.log ... (remaining 3 arguments skipped)

Use --sandbox_debug to see verbose messages from the sandbox and retain the sandbox build root for debugging
--- Command Failed ---
Status: 255
Command: bazel-out/darwin_arm64-opt-exec-ST-d57f47055a04/bin/HelloWorld bazel-out/darwin_arm64-fastbuild/bin/hello_world.txt
--- STDOUT ---
hello world
--- STDERR ---
hello world
--- End of Output ---
Target //:hello_world failed to build
Use --verbose_failures to see the command lines of failed build steps.
INFO: Elapsed time: 0.459s, Critical Path: 0.15s
INFO: 2 processes: 2 action cache hit, 2 internal.
ERROR: Build did NOT complete successfully

Finally, we can use --output_groups to get access to the output on successful builds.

> bazel build //:hello_world --output_groups=output
INFO: Invocation ID: f2341485-42f3-4117-aced-bfdd87ef60ca
INFO: Analyzed target //:hello_world (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //:hello_world up-to-date:
  bazel-bin/hello_world.err.log
  bazel-bin/hello_world.out.log
INFO: Elapsed time: 0.369s, Critical Path: 0.08s
INFO: 3 processes: 1 disk cache hit, 1 internal, 1 darwin-sandbox.
INFO: Build completed successfully, 3 total actions

This allows us to access bazel-bin/hello_world.out.log, for instance, so we can see the output quite nicely! 💪

It’s a bit annoying we have to all keep rebuilding this infrastructure ourselves but hopefully this demystifies it for you and you can enter build nirvana with me.


Improve this page @ 6d377c6
The content for this site is CC-BY-SA.