internal/logs/gcphandler: slog handler for GCP

Add a package for writing logs in a way that GCP can
understand. For example, changing "level" to "severity" will result
in the level being displayed in a special way in the console log viewer.

Change-Id: Ib34c7347ae8e901a93c032b2a2b8ca13ffb18b8c
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/602035
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/logs/gcphandler/gcphandler.go b/internal/logs/gcphandler/gcphandler.go
new file mode 100644
index 0000000..a56c859
--- /dev/null
+++ b/internal/logs/gcphandler/gcphandler.go
@@ -0,0 +1,56 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package gcphandler implements a slog.Handler that works
+// with the Google Cloud Platform's logging service.
+// It always writes to stderr, so it is most suitable for
+// programs that run on a managed service that treats JSON
+// lines written to stderr as logs, like Cloud Run and AppEngine.
+package gcphandler
+
+import (
+	"io"
+	"log/slog"
+	"os"
+	"time"
+)
+
+// New creates a new [slog.Handler] for GCP logging.
+func New(level slog.Leveler) slog.Handler {
+	return newHandler(level, os.Stderr)
+}
+
+// newHandler is for testing.
+func newHandler(level slog.Leveler, w io.Writer) slog.Handler {
+	return slog.NewJSONHandler(w, &slog.HandlerOptions{
+		Level:       level,
+		ReplaceAttr: replaceAttr,
+	})
+}
+
+// If testTime is non-zero, replaceAttr will use it as the time.
+var testTime time.Time
+
+// replaceAttr uses GCP names for certain fields.
+// It also formats times in the way that GCP expects.
+// See https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields.
+func replaceAttr(groups []string, a slog.Attr) slog.Attr {
+	switch a.Key {
+	case "time":
+		if a.Value.Kind() == slog.KindTime {
+			tm := a.Value.Time()
+			if !testTime.IsZero() {
+				tm = testTime
+			}
+			a.Value = slog.StringValue(tm.Format(time.RFC3339))
+		}
+	case "msg":
+		a.Key = "message"
+	case "level":
+		a.Key = "severity"
+	case "traceID":
+		a.Key = "logging.googleapis.com/trace"
+	}
+	return a
+}
diff --git a/internal/logs/gcphandler/gcphandler_test.go b/internal/logs/gcphandler/gcphandler_test.go
new file mode 100644
index 0000000..1630eb4
--- /dev/null
+++ b/internal/logs/gcphandler/gcphandler_test.go
@@ -0,0 +1,29 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package gcphandler
+
+import (
+	"bytes"
+	"fmt"
+	"log/slog"
+	"testing"
+	"time"
+)
+
+func TestHandler(t *testing.T) {
+	var buf bytes.Buffer
+	l := slog.New(newHandler(slog.LevelInfo, &buf))
+	l = l.With("traceID", "tid")
+	testTime = time.Now()
+	l.Info("hello", slog.String("foo", "bar"), slog.Int("count", 7))
+	got := buf.String()
+
+	want := fmt.Sprintf(`{"time":%q,"severity":"INFO","message":"hello","logging.googleapis.com/trace":"tid","foo":"bar","count":7}`,
+		testTime.Format(time.RFC3339))
+	want += "\n"
+	if got != want {
+		t.Errorf("\ngot  %s\nwant %s", got, want)
+	}
+}