Add support for serializing Any types to BEP's JSON transport.

Followup from issue #19471 to include details specific to execution strategy
for the select actions included in BEP.

PiperOrigin-RevId: 577279639
Change-Id: I112182134f1ce798c22f8f81998a14040344c191
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD b/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
index f627aca..db79ef5 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/buildeventservice/BUILD
@@ -37,7 +37,6 @@
     ),
     deps = [
         ":buildeventservice-options",
-        "//src/main/java/com/google/devtools/build/lib:build-request-options",
         "//src/main/java/com/google/devtools/build/lib:runtime",
         "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis:test/test_configuration",
@@ -62,6 +61,7 @@
         "//src/main/java/com/google/devtools/build/lib/util/io:out-err",
         "//src/main/java/com/google/devtools/common/options",
         "//src/main/protobuf:failure_details_java_proto",
+        "//src/main/protobuf:spawn_java_proto",
         "//third_party:auth",
         "//third_party:auto_value",
         "//third_party:flogger",
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventservice/BuildEventServiceModule.java b/src/main/java/com/google/devtools/build/lib/buildeventservice/BuildEventServiceModule.java
index 6b2afdf..8e8db9c 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventservice/BuildEventServiceModule.java
+++ b/src/main/java/com/google/devtools/build/lib/buildeventservice/BuildEventServiceModule.java
@@ -50,6 +50,7 @@
 import com.google.devtools.build.lib.events.Event;
 import com.google.devtools.build.lib.events.EventHandler;
 import com.google.devtools.build.lib.events.Reporter;
+import com.google.devtools.build.lib.exec.Protos.SpawnExec;
 import com.google.devtools.build.lib.network.ConnectivityStatus;
 import com.google.devtools.build.lib.network.ConnectivityStatus.Status;
 import com.google.devtools.build.lib.network.ConnectivityStatusProvider;
@@ -72,6 +73,7 @@
 import com.google.devtools.common.options.OptionsBase;
 import com.google.devtools.common.options.OptionsParsingException;
 import com.google.devtools.common.options.OptionsParsingResult;
+import com.google.protobuf.util.JsonFormat.TypeRegistry;
 import com.google.protobuf.util.Timestamps;
 import java.io.BufferedOutputStream;
 import java.io.IOException;
@@ -748,6 +750,16 @@
         .build();
   }
 
+  /**
+   * Returns the JSON type registry, used to resolve {@code Any} type names at serialization time.
+   *
+   * <p>Intended to be overridden by custom build tools with a subclassed {@link
+   * BuildEventServiceModule} to add additional Any types to be produced.
+   */
+  protected TypeRegistry makeJsonTypeRegistry() {
+    return TypeRegistry.newBuilder().add(SpawnExec.getDescriptor()).build();
+  }
+
   private ImmutableSet<BuildEventTransport> createBepTransports(
       CommandEnvironment cmdEnv,
       ThrowingBuildEventArtifactUploaderSupplier uploaderSupplier,
@@ -820,7 +832,11 @@
                 : new LocalFilesArtifactUploader();
         bepTransportsBuilder.add(
             new JsonFormatFileTransport(
-                bepJsonOutputStream, bepOptions, localFileUploader, artifactGroupNamer));
+                bepJsonOutputStream,
+                bepOptions,
+                localFileUploader,
+                artifactGroupNamer,
+                makeJsonTypeRegistry()));
       } catch (IOException exception) {
         // TODO(b/125216340): Consider making this a warning instead of an error once the
         //  associated bug has been resolved.
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto b/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto
index f70bbbe..8520be2 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto
+++ b/src/main/java/com/google/devtools/build/lib/buildeventstream/proto/build_event_stream.proto
@@ -585,7 +585,11 @@
   // End of action execution, after all attempted execution completes.
   google.protobuf.Timestamp end_time = 13;
 
-  // Additional details about action execution supplied by any/all strategies.
+  // Additional details about action execution supplied by strategies. Bazel
+  // options will determine which strategy details are included when multiple
+  // strategies are involved in a single action's execution.
+  //
+  // The default type will be `tools.proto.SpawnExec` found in `spawn.proto`.
   repeated google.protobuf.Any strategy_details = 14;
 }
 
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD b/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
index 1dacd15..b74731e 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
@@ -22,6 +22,7 @@
         "//src/main/java/com/google/devtools/common/options",
         "//src/main/protobuf:failure_details_java_proto",
         "//third_party:flogger",
+        "//third_party:gson",
         "//third_party:guava",
         "//third_party:jsr305",
         "//third_party/protobuf:protobuf_java",
diff --git a/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransport.java b/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransport.java
index a250741..5d7af3a 100644
--- a/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransport.java
+++ b/src/main/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransport.java
@@ -16,13 +16,18 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.GoogleLogger;
 import com.google.devtools.build.lib.buildeventstream.ArtifactGroupNamer;
 import com.google.devtools.build.lib.buildeventstream.BuildEventArtifactUploader;
 import com.google.devtools.build.lib.buildeventstream.BuildEventProtocolOptions;
 import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos;
 import com.google.devtools.build.lib.buildeventstream.BuildEventTransport;
+import com.google.gson.GsonBuilder;
 import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.protobuf.util.JsonFormat;
+import com.google.protobuf.util.JsonFormat.Printer;
+import com.google.protobuf.util.JsonFormat.TypeRegistry;
 import java.io.BufferedOutputStream;
 
 /**
@@ -30,12 +35,22 @@
  * representation of the events to a file.
  */
 public final class JsonFormatFileTransport extends FileTransport {
+  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
+
+  private static final String UNKNOWN_ANY_TYPE_ERROR_EVENT =
+      new GsonBuilder().create().toJson(new UnknownAnyProtoError());
+
+  private final Printer jsonPrinter;
+
   public JsonFormatFileTransport(
       BufferedOutputStream outputStream,
       BuildEventProtocolOptions options,
       BuildEventArtifactUploader uploader,
-      ArtifactGroupNamer namer) {
+      ArtifactGroupNamer namer,
+      TypeRegistry typeRegistry) {
     super(outputStream, options, uploader, namer);
+    jsonPrinter =
+        JsonFormat.printer().usingTypeRegistry(typeRegistry).omittingInsignificantWhitespace();
   }
 
   @Override
@@ -47,14 +62,24 @@
   protected byte[] serializeEvent(BuildEventStreamProtos.BuildEvent buildEvent) {
     String protoJsonRepresentation;
     try {
-      protoJsonRepresentation =
-          JsonFormat.printer().omittingInsignificantWhitespace().print(buildEvent) + "\n";
+      protoJsonRepresentation = jsonPrinter.print(buildEvent);
     } catch (InvalidProtocolBufferException e) {
       // We don't expect any unknown Any fields in our protocol buffer. Nevertheless, handle
       // the exception gracefully and, at least, return valid JSON with an id field.
-      protoJsonRepresentation =
-          "{\"id\" : \"unknown\", \"exception\" : \"InvalidProtocolBufferException\"}\n";
+      logger.atWarning().withCause(e).log(
+          "Failed to serialize to JSON due to Any type resolution failure: %s", buildEvent);
+      protoJsonRepresentation = UNKNOWN_ANY_TYPE_ERROR_EVENT;
     }
-    return protoJsonRepresentation.getBytes(UTF_8);
+    return (protoJsonRepresentation + "\n").getBytes(UTF_8);
+  }
+
+  /** Error produced when serializing an {@code Any} protobuf whose contained type is unknown. */
+  @VisibleForTesting
+  static class UnknownAnyProtoError {
+    @SuppressWarnings({"FieldCanBeStatic", "unused"}) // Used by Gson formatting; cannot be static
+    private final String id = "unknown";
+
+    @SuppressWarnings({"FieldCanBeStatic", "unused"}) // Used by Gson formatting; cannot be static
+    private final String exception = "InvalidProtocolBufferException";
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD b/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
index f0ccefc..3caaa10 100644
--- a/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/BUILD
@@ -24,6 +24,8 @@
         "//src/main/java/com/google/devtools/build/lib/buildeventstream/transports",
         "//src/main/java/com/google/devtools/build/lib/vfs",
         "//src/main/java/com/google/devtools/common/options",
+        "//src/main/protobuf:spawn_java_proto",
+        "//third_party:gson",
         "//third_party:guava",
         "//third_party:junit4",
         "//third_party:mockito",
diff --git a/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransportTest.java b/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransportTest.java
index 3635efb..fe9dd01 100644
--- a/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransportTest.java
+++ b/src/test/java/com/google/devtools/build/lib/buildeventstream/transports/JsonFormatFileTransportTest.java
@@ -15,28 +15,37 @@
 package com.google.devtools.build.lib.buildeventstream.transports;
 
 import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.mockito.Mockito.when;
 
 import com.google.devtools.build.lib.buildeventstream.ArtifactGroupNamer;
 import com.google.devtools.build.lib.buildeventstream.BuildEvent;
-import com.google.devtools.build.lib.buildeventstream.BuildEventContext;
 import com.google.devtools.build.lib.buildeventstream.BuildEventProtocolOptions;
 import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.ActionExecuted;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.BuildEventId;
+import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.BuildEventId.ActionCompletedId;
 import com.google.devtools.build.lib.buildeventstream.BuildEventStreamProtos.BuildStarted;
 import com.google.devtools.build.lib.buildeventstream.LocalFilesArtifactUploader;
 import com.google.devtools.build.lib.buildeventstream.PathConverter;
+import com.google.devtools.build.lib.buildeventstream.transports.JsonFormatFileTransport.UnknownAnyProtoError;
+import com.google.devtools.build.lib.exec.Protos.SpawnExec;
 import com.google.devtools.common.options.Options;
+import com.google.gson.GsonBuilder;
+import com.google.protobuf.Any;
 import com.google.protobuf.util.JsonFormat;
+import com.google.protobuf.util.JsonFormat.TypeRegistry;
 import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.Reader;
 import java.nio.file.Files;
 import java.nio.file.Paths;
-import org.junit.After;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -45,53 +54,60 @@
 import org.junit.runners.JUnit4;
 import org.mockito.ArgumentMatchers;
 import org.mockito.Mock;
-import org.mockito.Mockito;
-import org.mockito.MockitoAnnotations;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.quality.Strictness;
 
-/** Tests {@link TextFormatFileTransport}. * */
+/** Tests {@link JsonFormatFileTransport}. */
 @RunWith(JUnit4.class)
 public class JsonFormatFileTransportTest {
+
+  private static final TypeRegistry SPAWN_EXEC_TYPE_REGISTRY =
+      TypeRegistry.newBuilder().add(SpawnExec.getDescriptor()).build();
   private final BuildEventProtocolOptions defaultOpts =
       Options.getDefaults(BuildEventProtocolOptions.class);
 
   @Rule public TemporaryFolder tmp = new TemporaryFolder();
+  @Rule public MockitoRule mocks = MockitoJUnit.rule().strictness(Strictness.STRICT_STUBS);
 
   @Mock public BuildEvent buildEvent;
 
   @Mock public PathConverter pathConverter;
   @Mock public ArtifactGroupNamer artifactGroupNamer;
 
+  private File output = null;
+  private BufferedOutputStream outputStream = null;
+  private JsonFormatFileTransport underTest = null;
+
   @Before
-  public void setUp() {
-    MockitoAnnotations.initMocks(this);
-  }
-
-  @After
-  public void tearDown() {
-    Mockito.validateMockitoUsage();
-  }
-
-  @Test
-  public void testCreatesFileAndWritesProtoJsonFormat() throws Exception {
-    File output = tmp.newFile();
-    BufferedOutputStream outputStream =
+  public void setUp() throws IOException {
+    output = tmp.newFile();
+    outputStream =
         new BufferedOutputStream(Files.newOutputStream(Paths.get(output.getAbsolutePath())));
-
-    BuildEventStreamProtos.BuildEvent started =
-        BuildEventStreamProtos.BuildEvent.newBuilder()
-            .setStarted(BuildStarted.newBuilder().setCommand("build"))
-            .build();
-    when(buildEvent.asStreamProto(ArgumentMatchers.<BuildEventContext>any())).thenReturn(started);
-    JsonFormatFileTransport transport =
+    underTest =
         new JsonFormatFileTransport(
             outputStream,
             defaultOpts,
             new LocalFilesArtifactUploader(),
-            artifactGroupNamer);
-    transport.sendBuildEvent(buildEvent);
+            artifactGroupNamer,
+            SPAWN_EXEC_TYPE_REGISTRY);
+  }
 
-    transport.close().get();
-    try (Reader reader = new InputStreamReader(new FileInputStream(output))) {
+  @Test
+  public void testCreatesFileAndWritesProtoJsonFormat() throws Exception {
+    // Arrange: Prepare a simple BEP event that can be round-tripped.
+    BuildEventStreamProtos.BuildEvent started =
+        BuildEventStreamProtos.BuildEvent.newBuilder()
+            .setStarted(BuildStarted.newBuilder().setCommand("build"))
+            .build();
+    when(buildEvent.asStreamProto(ArgumentMatchers.any())).thenReturn(started);
+
+    // Act: Send the simple BuildStarted event.
+    underTest.sendBuildEvent(buildEvent);
+    underTest.close().get();
+
+    // Assert: Read back the BEP event and confirm it round-tripped.
+    try (Reader reader = openOutputReader()) {
       JsonFormat.Parser parser = JsonFormat.parser();
       BuildEventStreamProtos.BuildEvent.Builder builder =
           BuildEventStreamProtos.BuildEvent.newBuilder();
@@ -100,11 +116,104 @@
     }
   }
 
+  @Test
+  public void expandsKnownAnyType() throws Exception {
+    // Arrange: Prepare an Any event that is recognized by the JSON formatter.
+    Any spawnExecAny = Any.pack(SpawnExec.newBuilder().setExitCode(1).setMnemonic("Javac").build());
+    BuildEventStreamProtos.BuildEvent action = makeActionEventWithAnyDetails(spawnExecAny);
+    when(buildEvent.asStreamProto(ArgumentMatchers.any())).thenReturn(action);
+
+    // Act: Write the event and close the transport to force flushing.
+    underTest.sendBuildEvent(buildEvent);
+    underTest.close().get();
+
+    // Assert: Confirm we get the SpawnExec back.
+    try (Reader reader = openOutputReader()) {
+      JsonFormat.Parser parser = JsonFormat.parser().usingTypeRegistry(SPAWN_EXEC_TYPE_REGISTRY);
+      BuildEventStreamProtos.BuildEvent.Builder builder =
+          BuildEventStreamProtos.BuildEvent.newBuilder();
+      parser.merge(reader, builder);
+      assertThat(builder.build()).isEqualTo(action);
+    }
+  }
+
+  @Test
+  public void rejectsUnknownAnyType() throws Exception {
+    // Arrange: Prepare a BuildEvent that cannot be serialized due to an unrecognized Any type.
+    Any bogusAnyProto =
+        Any.pack(
+            BuildEventId.newBuilder()
+                .setActionCompleted(ActionCompletedId.newBuilder().setLabel("//:foo"))
+                .build());
+    BuildEventStreamProtos.BuildEvent action = makeActionEventWithAnyDetails(bogusAnyProto);
+    when(buildEvent.asStreamProto(ArgumentMatchers.any())).thenReturn(action);
+
+    // Act: Send the event with a bogus Any value.
+    underTest.sendBuildEvent(buildEvent);
+    underTest.close().get();
+
+    // Assert: Special invalid event message is written in JSON.
+    try (BufferedReader reader = new BufferedReader(openOutputReader())) {
+      String jsonLine = reader.readLine();
+      UnknownAnyProtoError error =
+          new GsonBuilder().create().fromJson(jsonLine, UnknownAnyProtoError.class);
+      assertThat(error).isNotNull();
+    }
+  }
+
+  @Test
+  public void testFlushesStreamAfterSmallWrites() throws Exception {
+    // Arrange: JsonFormatFileTransport writes to a wrapped output stream to verify flushing.
+    WrappedOutputStream wrappedOutputStream = new WrappedOutputStream(outputStream);
+    underTest =
+        new JsonFormatFileTransport(
+            wrappedOutputStream,
+            defaultOpts,
+            new LocalFilesArtifactUploader(),
+            artifactGroupNamer,
+            SPAWN_EXEC_TYPE_REGISTRY);
+
+    BuildEventStreamProtos.BuildEvent started =
+        BuildEventStreamProtos.BuildEvent.newBuilder()
+            .setStarted(BuildStarted.newBuilder().setCommand("build"))
+            .build();
+    when(buildEvent.asStreamProto(ArgumentMatchers.any())).thenReturn(started);
+
+    // Act: Write an event, then wait for three flush intervals.
+    underTest.sendBuildEvent(buildEvent);
+    Thread.sleep(underTest.getFlushInterval().toMillis() * 3);
+
+    // Assert: Confirm BEP events were written even though the file transport is not closed.
+
+    // Some users, e.g. Tulsi, use JSON build event output for interactive use and expect the stream
+    // to be flushed at regular short intervals.
+    assertThat(wrappedOutputStream.flushCount).isGreaterThan(0);
+
+    // We know that large writes get flushed; test is valuable only if we check small writes,
+    // meaning smaller than 8192, the default buffer size used by BufferedOutputStream.
+    assertThat(wrappedOutputStream.byteCount).isLessThan(8192L);
+    assertThat(wrappedOutputStream.byteCount).isGreaterThan(0L);
+
+    underTest.close().get();
+  }
+
+  private InputStreamReader openOutputReader() throws FileNotFoundException {
+    return new InputStreamReader(new FileInputStream(output), UTF_8);
+  }
+
+  private static BuildEventStreamProtos.BuildEvent makeActionEventWithAnyDetails(
+      Any strategyDetails) {
+    return BuildEventStreamProtos.BuildEvent.newBuilder()
+        .setAction(
+            ActionExecuted.newBuilder().setExitCode(1).addStrategyDetails(strategyDetails).build())
+        .build();
+  }
+
   /**
    * A thin wrapper around an OutputStream that counts number of bytes written and verifies flushes.
    *
    * <p>The methods below need to be synchronized because they override methods from {@link
-   * BufferedOutputStream} *not* because there's a concurrent access to the stream.
+   * BufferedOutputStream} *not* because there is concurrent access to the stream.
    */
   private static final class WrappedOutputStream extends BufferedOutputStream {
     private long byteCount;
@@ -139,36 +248,4 @@
       flushCount++;
     }
   }
-
-  @Test
-  public void testFlushesStreamAfterSmallWrites() throws Exception {
-    File output = tmp.newFile();
-    BufferedOutputStream outputStream =
-        new BufferedOutputStream(Files.newOutputStream(Paths.get(output.getAbsolutePath())));
-    WrappedOutputStream wrappedOutputStream = new WrappedOutputStream(outputStream);
-
-    BuildEventStreamProtos.BuildEvent started =
-        BuildEventStreamProtos.BuildEvent.newBuilder()
-            .setStarted(BuildStarted.newBuilder().setCommand("build"))
-            .build();
-    when(buildEvent.asStreamProto(ArgumentMatchers.<BuildEventContext>any())).thenReturn(started);
-
-    JsonFormatFileTransport transport =
-        new JsonFormatFileTransport(
-            wrappedOutputStream, defaultOpts, new LocalFilesArtifactUploader(), artifactGroupNamer);
-
-    transport.sendBuildEvent(buildEvent);
-    Thread.sleep(transport.getFlushInterval().toMillis() * 3);
-
-    // Some users, e.g. Tulsi, use JSON build event output for interactive use and expect the stream
-    // to be flushed at regular short intervals.
-    assertThat(wrappedOutputStream.flushCount).isGreaterThan(0);
-
-    // We know that large writes get flushed; test is valuable only if we check small writes,
-    // meaning smaller than 8192, the default buffer size used by BufferedOutputStream.
-    assertThat(wrappedOutputStream.byteCount).isLessThan(8192L);
-    assertThat(wrappedOutputStream.byteCount).isGreaterThan(0L);
-
-    transport.close().get();
-  }
 }