Logging the spawn graph on demand. First step on #4891.

This is a very heavy and slow option by design. It will be enabled only when a user wants to debug their build, most frequently to compare the step-by-step results of two builds.

TESTED: manually on various rules, including directories.
RELNOTES: None
PiperOrigin-RevId: 193539034
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index 43c4718..070f319 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -560,6 +560,7 @@
         "//src/main/protobuf:analysis_java_proto",
         "//src/main/protobuf:extra_actions_base_java_proto",
         "//src/main/protobuf:invocation_policy_java_proto",
+        "//src/main/protobuf:spawn_java_proto",
         "//src/main/protobuf:test_status_java_proto",
         "//third_party:auto_value",
         "//third_party:guava",
@@ -672,6 +673,7 @@
     ],
     deps = [
         ":bazel/BazelRepositoryModule",
+        ":exitcode-external",
         "//src/main/java/com/google/devtools/build/lib:bazel",
         "//src/main/java/com/google/devtools/build/lib:bazel-commands",
         "//src/main/java/com/google/devtools/build/lib:bazel-repository",
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
index 47c0e85..fde7dc7 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/Bazel.java
@@ -42,6 +42,7 @@
           com.google.devtools.build.lib.bazel.BazelWorkspaceStatusModule.class,
           com.google.devtools.build.lib.bazel.BazelDiffAwarenessModule.class,
           com.google.devtools.build.lib.bazel.BazelRepositoryModule.class,
+          com.google.devtools.build.lib.bazel.SpawnLogModule.class,
           com.google.devtools.build.lib.ssd.SsdModule.class,
           com.google.devtools.build.lib.worker.WorkerModule.class,
           com.google.devtools.build.lib.remote.RemoteModule.class,
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java b/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java
new file mode 100644
index 0000000..86ab87e
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/bazel/SpawnLogModule.java
@@ -0,0 +1,78 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.bazel;
+
+import com.google.devtools.build.lib.buildtool.BuildRequest;
+import com.google.devtools.build.lib.events.Event;
+import com.google.devtools.build.lib.exec.ActionContextConsumer;
+import com.google.devtools.build.lib.exec.ExecutionOptions;
+import com.google.devtools.build.lib.exec.ExecutorBuilder;
+import com.google.devtools.build.lib.exec.SpawnActionContextMaps;
+import com.google.devtools.build.lib.exec.SpawnLogContext;
+import com.google.devtools.build.lib.runtime.BlazeModule;
+import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.util.AbruptExitException;
+import com.google.devtools.build.lib.util.ExitCode;
+import com.google.devtools.build.lib.util.io.AsynchronousFileOutputStream;
+import java.io.IOException;
+
+/**
+ * Module providing on-demand spawn logging.
+ */
+public final class SpawnLogModule extends BlazeModule {
+
+  private static final class SpawnLogContextConsumer implements ActionContextConsumer {
+    @Override
+    public void populate(SpawnActionContextMaps.Builder builder) {
+      builder.strategyByContextMap().put(SpawnLogContext.class, "");
+    }
+  }
+
+  private SpawnLogContext spawnLogContext;
+
+  @Override
+  public void executorInit(CommandEnvironment env, BuildRequest request, ExecutorBuilder builder) {
+    env.getEventBus().register(this);
+    ExecutionOptions executionOptions = env.getOptions().getOptions(ExecutionOptions.class);
+    if (executionOptions != null
+        && executionOptions.executionLogFile != null
+        && !executionOptions.executionLogFile.isEmpty()) {
+      try {
+        spawnLogContext = new SpawnLogContext(
+            env.getExecRoot(),
+            new AsynchronousFileOutputStream(executionOptions.executionLogFile));
+      } catch (IOException e) {
+        env.getReporter().handle(Event.error(e.getMessage()));
+        env.getBlazeModuleEnvironment().exit(new AbruptExitException(ExitCode.COMMAND_LINE_ERROR));
+      }
+      builder.addActionContext(spawnLogContext);
+      builder.addActionContextConsumer(new SpawnLogContextConsumer());
+    } else {
+      spawnLogContext = null;
+    }
+  }
+
+  @Override
+  public void afterCommand() {
+    if (spawnLogContext != null) {
+      try {
+        spawnLogContext.close();
+      } catch (IOException e) {
+        throw new RuntimeException(e);
+      } finally {
+        spawnLogContext = null;
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java b/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java
index fee7f52..c516b78 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategy.java
@@ -31,6 +31,7 @@
 import com.google.devtools.build.lib.actions.SpawnResult;
 import com.google.devtools.build.lib.actions.SpawnResult.Status;
 import com.google.devtools.build.lib.actions.Spawns;
+import com.google.devtools.build.lib.events.Event;
 import com.google.devtools.build.lib.exec.SpawnCache.CacheHandle;
 import com.google.devtools.build.lib.exec.SpawnRunner.ProgressStatus;
 import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
@@ -86,6 +87,7 @@
       cache = SpawnCache.NO_CACHE;
     }
     SpawnResult spawnResult;
+    ExecException ex = null;
     try {
       try (CacheHandle cacheHandle = cache.lookup(spawn, context)) {
         if (cacheHandle.hasResult()) {
@@ -101,6 +103,30 @@
       }
     } catch (IOException e) {
       throw new EnvironmentalExecException("Unexpected IO error.", e);
+    } catch (SpawnExecException e) {
+      ex = e;
+      spawnResult = e.getSpawnResult();
+      // Log the Spawn and re-throw.
+    }
+
+    SpawnLogContext spawnLogContext = actionExecutionContext.getContext(SpawnLogContext.class);
+    if (spawnLogContext != null) {
+      try {
+        spawnLogContext.logSpawn(
+            spawn,
+            actionExecutionContext.getActionInputFileCache(),
+            context.getInputMapping(),
+            context.getTimeout(),
+            spawnResult);
+      } catch (IOException e) {
+        actionExecutionContext
+            .getEventHandler()
+            .handle(
+                Event.warn("Exception " + e + " while logging properties of " + spawn.toString()));
+      }
+    }
+    if (ex != null) {
+      throw ex;
     }
 
     if (spawnResult.status() != Status.SUCCESS) {
diff --git a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
index 8c7e2e1..b7f1b34 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/ExecutionOptions.java
@@ -275,6 +275,16 @@
   )
   public boolean enableCriticalPathProfiling;
 
+  @Option(
+    name = "experimental_execution_log_file",
+    defaultValue = "",
+    category = "verbosity",
+    documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+    effectTags = {OptionEffectTag.UNKNOWN},
+    help = "Log the executed spawns into this file as delimited Spawn protos."
+  )
+  public String executionLogFile;
+
   /** Converter for the --flaky_test_attempts option. */
   public static class TestAttemptsConverter extends PerLabelOptions.PerLabelOptionsConverter {
     private static final int MIN_VALUE = 1;
diff --git a/src/main/java/com/google/devtools/build/lib/exec/SpawnLogContext.java b/src/main/java/com/google/devtools/build/lib/exec/SpawnLogContext.java
new file mode 100644
index 0000000..c595235
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/exec/SpawnLogContext.java
@@ -0,0 +1,254 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.exec;
+
+import com.google.common.base.Preconditions;
+import com.google.common.hash.HashCode;
+import com.google.devtools.build.lib.actions.ActionContext;
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.ExecutionStrategy;
+import com.google.devtools.build.lib.actions.MetadataProvider;
+import com.google.devtools.build.lib.actions.Spawn;
+import com.google.devtools.build.lib.actions.SpawnResult;
+import com.google.devtools.build.lib.actions.Spawns;
+import com.google.devtools.build.lib.actions.cache.Metadata;
+import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
+import com.google.devtools.build.lib.analysis.platform.PlatformInfo;
+import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.exec.Protos.Digest;
+import com.google.devtools.build.lib.exec.Protos.File;
+import com.google.devtools.build.lib.exec.Protos.Platform;
+import com.google.devtools.build.lib.exec.Protos.SpawnExec;
+import com.google.devtools.build.lib.util.io.MessageOutputStream;
+import com.google.devtools.build.lib.vfs.Dirent;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.Symlinks;
+import com.google.protobuf.TextFormat;
+import com.google.protobuf.TextFormat.ParseException;
+import java.io.ByteArrayOutputStream;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javax.annotation.Nullable;
+
+/**
+ * A logging utility for spawns.
+ */
+@ExecutionStrategy(
+    name = {"spawn-log"},
+    contextType = SpawnLogContext.class
+)
+public class SpawnLogContext implements ActionContext {
+
+  private static final Logger logger = Logger.getLogger(SpawnLogContext.class.getName());
+  private final Path execRoot;
+  private final MessageOutputStream executionLog;
+
+  public SpawnLogContext(Path execRoot, MessageOutputStream executionLog) {
+    this.execRoot = execRoot;
+    this.executionLog = executionLog;
+  }
+
+  /** Log the executed spawn to the output stream. */
+  public void logSpawn(
+      Spawn spawn,
+      MetadataProvider metadataProvider,
+      SortedMap<PathFragment, ActionInput> inputMap,
+      Duration timeout,
+      SpawnResult result)
+      throws IOException {
+    SortedMap<Path, ActionInput> existingOutputs = listExistingOutputs(spawn);
+    SpawnExec.Builder builder = SpawnExec.newBuilder();
+    builder.addAllCommandArgs(spawn.getArguments());
+
+    Map<String, String> env = spawn.getEnvironment();
+    // Sorting the environment pairs by variable name.
+    TreeSet<String> variables = new TreeSet<>(env.keySet());
+    for (String var : variables) {
+      builder.addEnvironmentVariablesBuilder().setName(var).setValue(env.get(var));
+    }
+
+    try {
+      for (Map.Entry<PathFragment, ActionInput> e : inputMap.entrySet()) {
+        ActionInput input = e.getValue();
+        Path inputPath = execRoot.getRelative(input.getExecPathString());
+        if (inputPath.isDirectory()) {
+          listDirectoryContents(inputPath, (file) -> builder.addInputs(file), metadataProvider);
+        } else {
+          Digest digest = computeDigest(input, null, metadataProvider);
+          builder.addInputsBuilder().setPath(input.getExecPathString()).setDigest(digest);
+        }
+      }
+    } catch (IOException e) {
+      logger.log(Level.WARNING, "Error computing spawn inputs", e);
+    }
+    ArrayList<String> outputPaths = new ArrayList<>();
+    for (ActionInput output : spawn.getOutputFiles()) {
+      outputPaths.add(output.getExecPathString());
+    }
+    Collections.sort(outputPaths);
+    builder.addAllListedOutputs(outputPaths);
+    for (Map.Entry<Path, ActionInput> e : existingOutputs.entrySet()) {
+      Path path = e.getKey();
+      if (path.isDirectory()) {
+        listDirectoryContents(path, (file) -> builder.addActualOutputs(file), metadataProvider);
+      } else {
+        File.Builder outputBuilder = builder.addActualOutputsBuilder();
+        outputBuilder.setPath(path.relativeTo(execRoot).toString());
+        try {
+          outputBuilder.setDigest(computeDigest(e.getValue(), path, metadataProvider));
+        } catch (IOException ex) {
+          logger.log(Level.WARNING, "Error computing spawn event output properties", ex);
+        }
+      }
+    }
+    builder.setRemotable(Spawns.mayBeExecutedRemotely(spawn));
+
+    PlatformInfo execPlatform = spawn.getExecutionPlatform();
+    if (execPlatform != null && execPlatform.remoteExecutionProperties() != null) {
+      builder.setPlatform(
+          buildPlatform(execPlatform.label(), execPlatform.remoteExecutionProperties()));
+    }
+    if (result.status() != SpawnResult.Status.SUCCESS) {
+      builder.setStatus(result.status().toString());
+    }
+    if (!timeout.isZero()) {
+      builder.setTimeoutMillis(timeout.toMillis());
+    }
+    builder.setCacheable(Spawns.mayBeCached(spawn));
+    builder.setExitCode(result.exitCode());
+    builder.setRemoteCacheHit(result.isCacheHit());
+    builder.setRunner(result.getRunnerName());
+    String progressMessage = spawn.getResourceOwner().getProgressMessage();
+    if (progressMessage != null) {
+      builder.setProgressMessage(progressMessage);
+    }
+    builder.setMnemonic(spawn.getMnemonic());
+    executionLog.write(builder.build());
+  }
+
+  public void close() throws IOException {
+    executionLog.close();
+  }
+
+  private static Platform buildPlatform(Label platformLabel, @Nullable String platformDescription) {
+    Platform.Builder platformBuilder = Platform.newBuilder();
+    try {
+      if (platformDescription != null) {
+        TextFormat.getParser().merge(platformDescription, platformBuilder);
+      }
+    } catch (ParseException e) {
+      throw new IllegalArgumentException(
+          String.format(
+              "Failed to parse remote_execution_properties from platform %s", platformLabel),
+          e);
+    }
+    return platformBuilder.build();
+  }
+
+  private SortedMap<Path, ActionInput> listExistingOutputs(Spawn spawn) {
+    TreeMap<Path, ActionInput> result = new TreeMap<>();
+    for (ActionInput output : spawn.getOutputFiles()) {
+      Path outputPath = execRoot.getRelative(output.getExecPathString());
+      // TODO(olaola): once symlink API proposal is implemented, report symlinks here.
+      if (outputPath.exists()) {
+        result.put(outputPath, output);
+      }
+    }
+    return result;
+  }
+
+  private void listDirectoryContents(
+      Path path, Consumer<File> addFile, MetadataProvider metadataProvider) {
+    try {
+      // TODO(olaola): once symlink API proposal is implemented, report symlinks here.
+      List<Dirent> sortedDirent = new ArrayList<>(path.readdir(Symlinks.NOFOLLOW));
+      sortedDirent.sort(Comparator.comparing(Dirent::getName));
+      for (Dirent dirent : sortedDirent) {
+        String name = dirent.getName();
+        Path child = path.getRelative(name);
+        if (dirent.getType() == Dirent.Type.DIRECTORY) {
+          listDirectoryContents(child, addFile, metadataProvider);
+        } else {
+          addFile.accept(
+              File.newBuilder()
+                  .setPath(child.relativeTo(execRoot).toString())
+                  .setDigest(computeDigest(null, child, metadataProvider))
+                  .build());
+        }
+      }
+    } catch (IOException e) {
+      logger.log(Level.WARNING, "Error computing spawn event file properties", e);
+    }
+  }
+
+  /**
+   * Computes the digest of the given ActionInput or corresponding path. Will try to access the
+   * Metadata cache first, if it is available, and fall back to digesting the contents manually.
+   */
+  private Digest computeDigest(
+      @Nullable ActionInput input, @Nullable Path path, MetadataProvider metadataProvider)
+      throws IOException {
+    Preconditions.checkArgument(input != null || path != null);
+    FileSystem.HashFunction hashFunction = execRoot.getFileSystem().getDigestFunction();
+    Digest.Builder digest = Digest.newBuilder().setHashFunctionName(hashFunction.toString());
+    if (input != null) {
+      if (input instanceof VirtualActionInput) {
+        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+        ((VirtualActionInput) input).writeTo(buffer);
+        byte[] blob = buffer.toByteArray();
+        return digest
+            .setHash(hashFunction.getHash().hashBytes(blob).toString())
+            .setSizeBytes(blob.length)
+            .build();
+      }
+      // Try to access the cached metadata, otherwise fall back to local computation.
+      try {
+        Metadata metadata = metadataProvider.getMetadata(input);
+        if (metadata != null) {
+          byte[] hash = metadata.getDigest();
+          if (hash != null) {
+            return digest
+                .setHash(HashCode.fromBytes(hash).toString())
+                .setSizeBytes(metadata.getSize())
+                .build();
+          }
+        }
+      } catch (IOException | IllegalStateException e) {
+        // Pass through to local computation.
+      }
+    }
+    if (path == null) {
+      path = execRoot.getRelative(input.getExecPath());
+    }
+    // Compute digest manually.
+    return digest
+        .setHash(HashCode.fromBytes(path.getDigest()).toString())
+        .setSizeBytes(path.getFileSize())
+        .build();
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java b/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
index 70284d0..531fe4b 100644
--- a/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
+++ b/src/main/java/com/google/devtools/build/lib/util/io/AsynchronousFileOutputStream.java
@@ -45,7 +45,7 @@
  * close future.
  */
 @ThreadSafety.ThreadSafe
-public class AsynchronousFileOutputStream extends OutputStream {
+public class AsynchronousFileOutputStream extends OutputStream implements MessageOutputStream {
   private final AsynchronousFileChannel ch;
   private final WriteCompletionHandler completionHandler = new WriteCompletionHandler();
   // The offset in the file to begin the next write at.
@@ -83,6 +83,7 @@
    * multiple calls to write on the underlying stream, so we have to provide this method here
    * instead of the caller using it directly.
    */
+  @Override
   public void write(Message m) {
     Preconditions.checkNotNull(m);
     final int size = m.getSerializedSize();
diff --git a/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStream.java b/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStream.java
new file mode 100644
index 0000000..56868cb
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/util/io/MessageOutputStream.java
@@ -0,0 +1,31 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.util.io;
+
+import com.google.protobuf.Message;
+import java.io.IOException;
+
+/**
+ * A variation of OutputStream for protobuf messages.
+ */
+public interface MessageOutputStream {
+  /**
+   * Writes a delimited protocol buffer message in the same format as {@link
+   * MessageLite#writeDelimitedTo(java.io.OutputStream)}.
+   */
+  void write(Message m) throws IOException;
+
+  /** Closes the underlying stream, following writes will fail. */
+  void close() throws IOException;
+}
diff --git a/src/main/protobuf/BUILD b/src/main/protobuf/BUILD
index cb44e63..684dcb2 100644
--- a/src/main/protobuf/BUILD
+++ b/src/main/protobuf/BUILD
@@ -27,6 +27,7 @@
     "invocation_policy",
     "java_compilation",
     "plmerge",
+    "spawn",
     "test_status",
     "worker_protocol",
 ]
diff --git a/src/main/protobuf/spawn.proto b/src/main/protobuf/spawn.proto
new file mode 100644
index 0000000..b6c6ec3
--- /dev/null
+++ b/src/main/protobuf/spawn.proto
@@ -0,0 +1,122 @@
+// Copyright 2018 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package tools.protos;
+
+option java_package = "com.google.devtools.build.lib.exec";
+option java_outer_classname = "Protos";
+
+message Digest {
+  // Digest of a file's contents using the current FileSystem digest function.
+  string hash = 1;
+
+  // The size in bytes of the original content.
+  int64 size_bytes = 2;
+
+  // The digest function that was used to generate the hash.
+  // This is not an enum for compatibility reasons, and also because the
+  // purpose of these logs is to enable analysis by comparison of multiple
+  // builds. So, from the programmatic perspective, this is an opaque field.
+  string hash_function_name = 3;
+}
+
+message File {
+  // Path to the file relative to the execution root.
+  string path = 1;
+
+  // Digest of the file's contents.
+  Digest digest = 2;
+}
+
+// Contents of command environment.
+message EnvironmentVariable {
+  string name = 1;
+  string value = 2;
+}
+
+// Command execution platform. This message needs to be kept in sync
+// with [Platform][google.devtools.remoteexecution.v1test.Platform].
+message Platform {
+  message Property {
+    string name = 1;
+    string value = 2;
+  }
+  repeated Property properties = 1;
+}
+
+// Details of an executed spawn.
+// These will only be generated on demand, using the
+// --execution_log_file=<path> flag.
+// Each message contains an executed command, its full inputs and outputs.
+// The purpose of these is to enable comparisons of multiple builds to diagnose
+// output differences or more subtle problems such as remote caching misses.
+// Only the executed Spawns will be output -- local cache hits are ignored.
+message SpawnExec {
+  // The command that was run.
+  repeated string command_args = 1;
+
+  // The command environment.
+  repeated EnvironmentVariable environment_variables = 2;
+
+  // The command execution platform.
+  Platform platform = 3;
+
+  // The inputs at the time of the execution.
+  repeated File inputs = 4;
+
+  // All the listed outputs paths. The paths are relative to the execution root.
+  // Actual outputs are a subset of the listed outputs. These paths are sorted.
+  repeated string listed_outputs = 5;
+
+  // Was the Spawn allowed to be executed remotely.
+  bool remotable = 6;
+
+  // Was the Spawn result allowed to be cached.
+  bool cacheable = 7;
+
+  // The Spawn timeout.
+  int64 timeout_millis = 8;
+
+  // A user-friendly text message representing the spawn progress.
+  string progress_message = 9;
+
+  // An opaque string that identifies the type of the Spawn's action.
+  string mnemonic = 10;
+
+  // The outputs generated by the execution.
+  repeated File actual_outputs = 11;
+
+  // If the Spawn was actually executed, rather than a remote cache hit,
+  // this will be the name of the runner executing the spawn, e.g. remote or
+  // linux-sandbox. Note this is not the same as the "strategy" string -- even
+  // though the action strategy may be remote, a particular action may still
+  // fall back to local execution due to a variety of reasons. This field
+  // indicates what really happened for the particular Spawn+execution.
+  string runner = 12;
+
+  // Whether the Spawn was a remote cache hit, in which case it was not executed
+  // and the runner field will be empty.
+  bool remote_cache_hit = 13;
+
+  // A text status returned by the execution, in case there were any errors.
+  // Empty in case of successful execution.
+  string status = 14;
+
+  // This field contains the contents of SpawnResult.exitCode.
+  // Its semantics varies greatly depending on the status field.
+  // Dependable: if status is empty, exit_code is guaranteed to be zero.
+  int32 exit_code = 15;
+}
diff --git a/src/test/java/com/google/devtools/build/lib/BUILD b/src/test/java/com/google/devtools/build/lib/BUILD
index 0580838..03860d2 100644
--- a/src/test/java/com/google/devtools/build/lib/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/BUILD
@@ -1386,6 +1386,7 @@
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//src/main/java/com/google/devtools/build/lib/vfs/inmemoryfs",
         "//src/main/java/com/google/devtools/common/options",
+        "//src/main/protobuf:spawn_java_proto",
         "//src/main/protobuf:test_status_java_proto",
         "//third_party:mockito",
         "//third_party/protobuf:protobuf_java",
diff --git a/src/test/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategyTest.java b/src/test/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategyTest.java
index 43f6e65..ab3bc42 100644
--- a/src/test/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategyTest.java
+++ b/src/test/java/com/google/devtools/build/lib/exec/AbstractSpawnStrategyTest.java
@@ -23,16 +23,26 @@
 import static org.mockito.Mockito.when;
 
 import com.google.devtools.build.lib.actions.ActionExecutionContext;
+import com.google.devtools.build.lib.actions.ActionInputFileCache;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.ArtifactRoot;
 import com.google.devtools.build.lib.actions.Spawn;
 import com.google.devtools.build.lib.actions.SpawnResult;
 import com.google.devtools.build.lib.actions.SpawnResult.Status;
+import com.google.devtools.build.lib.exec.Protos.Digest;
+import com.google.devtools.build.lib.exec.Protos.EnvironmentVariable;
+import com.google.devtools.build.lib.exec.Protos.File;
+import com.google.devtools.build.lib.exec.Protos.SpawnExec;
 import com.google.devtools.build.lib.exec.SpawnCache.CacheHandle;
 import com.google.devtools.build.lib.exec.SpawnRunner.SpawnExecutionContext;
 import com.google.devtools.build.lib.exec.util.SpawnBuilder;
+import com.google.devtools.build.lib.testutil.Scratch;
 import com.google.devtools.build.lib.testutil.Suite;
 import com.google.devtools.build.lib.testutil.TestSpec;
+import com.google.devtools.build.lib.util.io.MessageOutputStream;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.Root;
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import java.util.Collection;
 import java.util.List;
@@ -58,18 +68,23 @@
 
   private final FileSystem fs = new InMemoryFileSystem();
   private final Path execRoot = fs.getPath("/execroot");
+  private Scratch scratch;
+  private ArtifactRoot rootDir;
   @Mock private SpawnRunner spawnRunner;
   @Mock private ActionExecutionContext actionExecutionContext;
+  @Mock private MessageOutputStream messageOutput;
 
   @Before
   public final void setUp() throws Exception {
     MockitoAnnotations.initMocks(this);
+    scratch = new Scratch(fs);
+    rootDir = ArtifactRoot.asSourceRoot(Root.fromPath(scratch.dir("/execroot")));
   }
 
   @Test
   public void testZeroExit() throws Exception {
     when(actionExecutionContext.getContext(eq(SpawnCache.class))).thenReturn(SpawnCache.NO_CACHE);
-    when(actionExecutionContext.getExecRoot()).thenReturn(fs.getPath("/execroot"));
+    when(actionExecutionContext.getExecRoot()).thenReturn(execRoot);
     SpawnResult spawnResult =
         new SpawnResult.Builder().setStatus(Status.SUCCESS).setRunnerName("test").build();
     when(spawnRunner.exec(any(Spawn.class), any(SpawnExecutionContext.class)))
@@ -115,7 +130,7 @@
     when(cache.lookup(any(Spawn.class), any(SpawnExecutionContext.class)))
         .thenReturn(SpawnCache.success(spawnResult));
     when(actionExecutionContext.getContext(eq(SpawnCache.class))).thenReturn(cache);
-    when(actionExecutionContext.getExecRoot()).thenReturn(fs.getPath("/execroot"));
+    when(actionExecutionContext.getExecRoot()).thenReturn(execRoot);
 
     List<SpawnResult> spawnResults =
         new TestedSpawnStrategy(execRoot, spawnRunner).exec(SIMPLE_SPAWN, actionExecutionContext);
@@ -133,7 +148,7 @@
     when(entry.willStore()).thenReturn(true);
 
     when(actionExecutionContext.getContext(eq(SpawnCache.class))).thenReturn(cache);
-    when(actionExecutionContext.getExecRoot()).thenReturn(fs.getPath("/execroot"));
+    when(actionExecutionContext.getExecRoot()).thenReturn(execRoot);
     SpawnResult spawnResult =
         new SpawnResult.Builder().setStatus(Status.SUCCESS).setRunnerName("test").build();
     when(spawnRunner.exec(any(Spawn.class), any(SpawnExecutionContext.class)))
@@ -159,7 +174,7 @@
     when(entry.willStore()).thenReturn(true);
 
     when(actionExecutionContext.getContext(eq(SpawnCache.class))).thenReturn(cache);
-    when(actionExecutionContext.getExecRoot()).thenReturn(fs.getPath("/execroot"));
+    when(actionExecutionContext.getExecRoot()).thenReturn(execRoot);
     SpawnResult result =
         new SpawnResult.Builder()
             .setStatus(Status.NON_ZERO_EXIT)
@@ -179,4 +194,90 @@
     verify(spawnRunner).exec(any(Spawn.class), any(SpawnExecutionContext.class));
     verify(entry).store(eq(result), any(Collection.class));
   }
+
+  @Test
+  public void testLogSpawn() throws Exception {
+    when(actionExecutionContext.getContext(eq(SpawnCache.class))).thenReturn(SpawnCache.NO_CACHE);
+    when(actionExecutionContext.getExecRoot()).thenReturn(execRoot);
+    when(actionExecutionContext.getContext(eq(SpawnLogContext.class)))
+        .thenReturn(new SpawnLogContext(execRoot, messageOutput));
+    when(spawnRunner.exec(any(Spawn.class), any(SpawnExecutionContext.class)))
+        .thenReturn(
+            new SpawnResult.Builder()
+                .setStatus(Status.NON_ZERO_EXIT)
+                .setExitCode(23)
+                .setRunnerName("runner")
+                .build());
+    when(actionExecutionContext.getActionInputFileCache())
+        .thenReturn(mock(ActionInputFileCache.class));
+
+    Artifact input = new Artifact(scratch.file("/execroot/foo", "1"), rootDir);
+    scratch.file("/execroot/out1", "123");
+    scratch.file("/execroot/out2", "123");
+    Spawn spawn =
+        new SpawnBuilder("/bin/echo", "Foo!")
+            .withEnvironment("FOO", "v1")
+            .withEnvironment("BAR", "v2")
+            .withMnemonic("MyMnemonic")
+            .withProgressMessage("my progress message")
+            .withInput(input)
+            .withOutputs("out2", "out1")
+            .build();
+    try {
+      new TestedSpawnStrategy(execRoot, spawnRunner).exec(spawn, actionExecutionContext);
+      fail("expected failure");
+    } catch (SpawnExecException expected) {
+      // Should throw.
+    }
+
+    SpawnExec expectedSpawnLog =
+        SpawnExec.newBuilder()
+            .addCommandArgs("/bin/echo")
+            .addCommandArgs("Foo!")
+            .addEnvironmentVariables(
+                EnvironmentVariable.newBuilder().setName("BAR").setValue("v2").build())
+            .addEnvironmentVariables(
+                EnvironmentVariable.newBuilder().setName("FOO").setValue("v1").build())
+            .addInputs(
+                File.newBuilder()
+                    .setPath("foo")
+                    .setDigest(
+                        Digest.newBuilder()
+                            .setHash("b026324c6904b2a9cb4b88d6d61c81d1")
+                            .setSizeBytes(2)
+                            .setHashFunctionName("MD5")
+                            .build())
+                    .build())
+            .addListedOutputs("out1")
+            .addListedOutputs("out2")
+            .addActualOutputs(
+                File.newBuilder()
+                    .setPath("out1")
+                    .setDigest(
+                        Digest.newBuilder()
+                            .setHash("ba1f2511fc30423bdbb183fe33f3dd0f")
+                            .setSizeBytes(4)
+                            .setHashFunctionName("MD5")
+                            .build())
+                    .build())
+            .addActualOutputs(
+                File.newBuilder()
+                    .setPath("out2")
+                    .setDigest(
+                        Digest.newBuilder()
+                            .setHash("ba1f2511fc30423bdbb183fe33f3dd0f")
+                            .setSizeBytes(4)
+                            .setHashFunctionName("MD5")
+                            .build())
+                    .build())
+            .setStatus("NON_ZERO_EXIT")
+            .setExitCode(23)
+            .setRemotable(true)
+            .setCacheable(true)
+            .setProgressMessage("my progress message")
+            .setMnemonic("MyMnemonic")
+            .setRunner("runner")
+            .build();
+    verify(messageOutput).write(expectedSpawnLog);
+  }
 }