Initial checkin: Quarkus Google Cloud JSON Logging.

Change-Id: I264211f56c2bed4002ecdb6ead8a5321ada855fd
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..887007b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+/.idea
+
+build/
+target/
+
+*~
+*.class
+*.iml
diff --git a/deployment/pom.xml b/deployment/pom.xml
new file mode 100644
index 0000000..5d34a10
--- /dev/null
+++ b/deployment/pom.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>eu.mulk.quarkus-observability</groupId>
+    <artifactId>quarkus-googlecloud-jsonlogging-parent</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+  </parent>
+  <artifactId>quarkus-googlecloud-jsonlogging-deployment</artifactId>
+  <name>Quarkus Google Cloud JSON Logging Extension - Deployment</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-arc-deployment</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-jsonb-deployment</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-jsonb-spi</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>eu.mulk.quarkus-observability</groupId>
+      <artifactId>quarkus-googlecloud-jsonlogging</artifactId>
+      <version>${project.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-junit5-internal</artifactId>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <annotationProcessorPaths>
+            <path>
+              <groupId>io.quarkus</groupId>
+              <artifactId>quarkus-extension-processor</artifactId>
+              <version>${quarkus.version}</version>
+            </path>
+          </annotationProcessorPaths>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+</project>
diff --git a/deployment/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingProcessor.java b/deployment/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingProcessor.java
new file mode 100644
index 0000000..8ecf000
--- /dev/null
+++ b/deployment/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingProcessor.java
@@ -0,0 +1,23 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.annotations.ExecutionTime;
+import io.quarkus.deployment.annotations.Record;
+import io.quarkus.deployment.builditem.FeatureBuildItem;
+import io.quarkus.deployment.builditem.LogConsoleFormatBuildItem;
+
+class GoogleCloudLoggingProcessor {
+
+  private static final String FEATURE = "googlecloud-jsonlogging";
+
+  @BuildStep
+  FeatureBuildItem feature() {
+    return new FeatureBuildItem(FEATURE);
+  }
+
+  @BuildStep
+  @Record(ExecutionTime.RUNTIME_INIT)
+  LogConsoleFormatBuildItem setUpFormatter(GoogleCloudLoggingRecorder recorder) {
+    return new LogConsoleFormatBuildItem(recorder.initialize());
+  }
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..a4e7701
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,81 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <groupId>eu.mulk.quarkus-observability</groupId>
+  <artifactId>quarkus-googlecloud-jsonlogging-parent</artifactId>
+  <version>1.0.0-SNAPSHOT</version>
+  <packaging>pom</packaging>
+  <name>Quarkus Google Cloud JSON Logging Extension - Parent</name>
+
+  <modules>
+    <module>deployment</module>
+    <module>runtime</module>
+  </modules>
+
+  <properties>
+    <maven.compiler.parameters>true</maven.compiler.parameters>
+    <maven.compiler.release>16</maven.compiler.release>
+
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+
+    <compiler-plugin.version>3.8.1</compiler-plugin.version>
+    <failsafe-plugin.version>${surefire-plugin.version}</failsafe-plugin.version>
+    <quarkus.version>1.13.3.Final</quarkus.version>
+    <surefire-plugin.version>3.0.0-M5</surefire-plugin.version>
+  </properties>
+
+  <dependencyManagement>
+    <dependencies>
+      <dependency>
+        <groupId>io.quarkus</groupId>
+        <artifactId>quarkus-bom</artifactId>
+        <version>${quarkus.version}</version>
+        <type>pom</type>
+        <scope>import</scope>
+      </dependency>
+    </dependencies>
+  </dependencyManagement>
+
+  <build>
+    <pluginManagement>
+      <plugins>
+        <plugin>
+          <groupId>io.quarkus</groupId>
+          <artifactId>quarkus-maven-plugin</artifactId>
+          <version>${quarkus.version}</version>
+        </plugin>
+        <plugin>
+          <artifactId>maven-surefire-plugin</artifactId>
+          <version>${surefire-plugin.version}</version>
+          <configuration>
+            <systemPropertyVariables>
+              <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
+              <maven.home>${maven.home}</maven.home>
+              <maven.repo>${settings.localRepository}</maven.repo>
+            </systemPropertyVariables>
+          </configuration>
+        </plugin>
+        <plugin>
+          <artifactId>maven-failsafe-plugin</artifactId>
+          <version>${failsafe-plugin.version}</version>
+          <configuration>
+            <systemPropertyVariables>
+              <java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
+              <maven.home>${maven.home}</maven.home>
+              <maven.repo>${settings.localRepository}</maven.repo>
+            </systemPropertyVariables>
+          </configuration>
+        </plugin>
+        <plugin>
+          <artifactId>maven-compiler-plugin</artifactId>
+          <version>${compiler-plugin.version}</version>
+        </plugin>
+      </plugins>
+    </pluginManagement>
+  </build>
+
+</project>
diff --git a/runtime/pom.xml b/runtime/pom.xml
new file mode 100644
index 0000000..e8254f9
--- /dev/null
+++ b/runtime/pom.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+
+  <modelVersion>4.0.0</modelVersion>
+
+  <parent>
+    <groupId>eu.mulk.quarkus-observability</groupId>
+    <artifactId>quarkus-googlecloud-jsonlogging-parent</artifactId>
+    <version>1.0.0-SNAPSHOT</version>
+  </parent>
+  <artifactId>quarkus-googlecloud-jsonlogging</artifactId>
+  <name>Quarkus Google Cloud JSON Logging Extension - Runtime</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-arc</artifactId>
+    </dependency>
+    <dependency>
+      <groupId>io.quarkus</groupId>
+      <artifactId>quarkus-jsonb</artifactId>
+    </dependency>
+  </dependencies>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>io.quarkus</groupId>
+        <artifactId>quarkus-bootstrap-maven-plugin</artifactId>
+        <version>${quarkus.version}</version>
+        <executions>
+          <execution>
+            <phase>compile</phase>
+            <goals>
+              <goal>extension-descriptor</goal>
+            </goals>
+            <configuration>
+              <deployment>${project.groupId}:${project.artifactId}-deployment:${project.version}</deployment>
+            </configuration>
+          </execution>
+        </executions>
+      </plugin>
+      <plugin>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <configuration>
+          <annotationProcessorPaths>
+            <path>
+              <groupId>io.quarkus</groupId>
+              <artifactId>quarkus-extension-processor</artifactId>
+              <version>${quarkus.version}</version>
+            </path>
+          </annotationProcessorPaths>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+</project>
diff --git a/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLogEntry.java b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLogEntry.java
new file mode 100644
index 0000000..3f5a836
--- /dev/null
+++ b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLogEntry.java
@@ -0,0 +1,41 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+import io.smallrye.common.constraint.Nullable;
+import java.time.Instant;
+import java.util.Map;
+import javax.json.bind.annotation.JsonbProperty;
+
+/**
+ * A JSON log entry compatible with Google Cloud Logging.
+ *
+ * <p>Roughly (but not quite) corresponds to Google Cloud Logging's
+ * <a href="https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry">LogEntry</a>
+ * structure.
+ */
+public record GoogleCloudLogEntry(
+    String getMessage,
+    String getSeverity,
+    Timestamp getTimestamp,
+    @Nullable String getTrace,
+    @Nullable String getSpanId,
+    @Nullable SourceLocation getSourceLocation,
+    @Nullable Map<String, String> getLabels,
+    @Nullable Map<String, Object> getParameters,
+    @Nullable Map<String, String> getMappedDiagnosticContext,
+    @Nullable String getNestedDiagnosticContext,
+    @Nullable @JsonbProperty("@type") String getType) {
+
+  static public record SourceLocation(
+      @Nullable String getFile,
+      @Nullable String getLine,
+      @Nullable String getFunction) {}
+
+  static public record Timestamp(
+      long getSeconds,
+      int getNanos) {
+
+    public Timestamp(Instant t) {
+      this(t.getEpochSecond(), t.getNano());
+    }
+  }
+}
diff --git a/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingFormatter.java b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingFormatter.java
new file mode 100644
index 0000000..a44e8b5
--- /dev/null
+++ b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingFormatter.java
@@ -0,0 +1,115 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.HashMap;
+import java.util.logging.Level;
+import javax.json.bind.Jsonb;
+import javax.json.bind.JsonbException;
+import org.jboss.logmanager.ExtFormatter;
+import org.jboss.logmanager.ExtLogRecord;
+
+/**
+ * Formats log records as JSON for consumption by Google Cloud Logging.
+ *
+ * <p>Meant to be used in containers running on Google Kubernetes Engine (GKE).
+ *
+ * @see GoogleCloudLogEntry
+ */
+class GoogleCloudLoggingFormatter extends ExtFormatter {
+
+  private static final String TRACE_LEVEL = "TRACE";
+  private static final String DEBUG_LEVEL = "DEBUG";
+  private static final String INFO_LEVEL = "INFO";
+  private static final String WARNING_LEVEL = "WARNING";
+  private static final String ERROR_LEVEL = "ERROR";
+
+  private static final String ERROR_EVENT_TYPE =
+      "type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent";
+
+  private final Jsonb jsonb;
+
+  GoogleCloudLoggingFormatter(Jsonb jsonb) {
+    this.jsonb = jsonb;
+  }
+
+  @Override
+  public String format(ExtLogRecord logRecord) {
+    var message = formatMessageWithStackTrace(logRecord);
+
+    var parameters = new HashMap<String, Object>();
+    var labels = new HashMap<String, String>();
+    if (logRecord.getParameters() != null) {
+      for (var parameter : logRecord.getParameters()) {
+        if (parameter instanceof KeyValueParameter kvparam) {
+          parameters.put(kvparam.key(), kvparam.value());
+        } else if (parameter instanceof Label label) {
+          labels.put(label.key(), label.value());
+        }
+      }
+    }
+
+    var mdc = logRecord.getMdcCopy();
+    var ndc = logRecord.getNdc();
+
+    var sourceLocation =
+        new GoogleCloudLogEntry.SourceLocation(
+            logRecord.getSourceFileName(), String.valueOf(logRecord.getSourceLineNumber()), String.format("%s.%s", logRecord.getSourceClassName(), logRecord.getSourceMethodName()));
+
+    var entry =
+        new GoogleCloudLogEntry(
+            message,
+            severityOf(logRecord.getLevel()),
+            new GoogleCloudLogEntry.Timestamp(logRecord.getInstant()),
+            null,
+            null,
+            sourceLocation,
+            labels.isEmpty() ? null : labels,
+            parameters.isEmpty() ? null : parameters,
+            mdc.isEmpty() ? null : mdc,
+            ndc.isEmpty() ? null : ndc,
+            logRecord.getLevel().intValue() >= 1000 ? ERROR_EVENT_TYPE : null);
+
+    try {
+      return jsonb.toJson(entry) + "\n";
+    } catch (JsonbException e) {
+      e.printStackTrace();
+      return message + "\n";
+    }
+  }
+
+  /**
+   * Formats the log message corresponding to {@code logRecord} including a stack trace of the
+   * {@link ExtLogRecord#getThrown()} exception if any.
+   */
+  private String formatMessageWithStackTrace(ExtLogRecord logRecord) {
+    var messageStringWriter = new StringWriter();
+    var messagePrintWriter = new PrintWriter(messageStringWriter);
+    messagePrintWriter.append(this.formatMessage(logRecord));
+
+    if (logRecord.getThrown() != null) {
+      messagePrintWriter.println();
+      logRecord.getThrown().printStackTrace(messagePrintWriter);
+    }
+
+    messagePrintWriter.close();
+    return messageStringWriter.toString();
+  }
+
+  /**
+   * Computes the Google Cloud Logging severity corresponding to a given {@link Level}.
+   */
+  private static String severityOf(Level level) {
+    if (level.intValue() < 500) {
+      return TRACE_LEVEL;
+    } else if (level.intValue() < 700) {
+      return DEBUG_LEVEL;
+    } else if (level.intValue() < 900) {
+      return INFO_LEVEL;
+    } else if (level.intValue() < 1000) {
+      return WARNING_LEVEL;
+    } else {
+      return ERROR_LEVEL;
+    }
+  }
+}
diff --git a/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingRecorder.java b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingRecorder.java
new file mode 100644
index 0000000..9ae3ae1
--- /dev/null
+++ b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/GoogleCloudLoggingRecorder.java
@@ -0,0 +1,16 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+import io.quarkus.runtime.RuntimeValue;
+import io.quarkus.runtime.annotations.Recorder;
+import java.util.Optional;
+import java.util.logging.Formatter;
+import javax.json.bind.spi.JsonbProvider;
+
+@Recorder
+public class GoogleCloudLoggingRecorder {
+
+  public RuntimeValue<Optional<Formatter>> initialize() {
+    var jsonb = JsonbProvider.provider().create().build();
+    return new RuntimeValue<>(Optional.of(new GoogleCloudLoggingFormatter(jsonb)));
+  }
+}
diff --git a/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/KeyValueParameter.java b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/KeyValueParameter.java
new file mode 100644
index 0000000..358e470
--- /dev/null
+++ b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/KeyValueParameter.java
@@ -0,0 +1,3 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+public record KeyValueParameter(String key, Object value) {}
diff --git a/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/Label.java b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/Label.java
new file mode 100644
index 0000000..0c13739
--- /dev/null
+++ b/runtime/src/main/java/eu/mulk/quarkus/observability/googlecloud/jsonlogging/Label.java
@@ -0,0 +1,3 @@
+package eu.mulk.quarkus.observability.googlecloud.jsonlogging;
+
+public record Label(String key, String value) {}
diff --git a/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/runtime/src/main/resources/META-INF/quarkus-extension.yaml
new file mode 100644
index 0000000..7aa7a2a
--- /dev/null
+++ b/runtime/src/main/resources/META-INF/quarkus-extension.yaml
@@ -0,0 +1,9 @@
+name: Google Cloud JSON Logging
+description: Logs to standard output in Google-Cloud-compatible JSON format.
+metadata:
+  keywords:
+    - logging
+  categories:
+    - "miscellaneous"
+  status: "preview"
+  # guide: ...