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)
+ }
+}