internal/log: write GCP-compatible logs on Cloud Run

When the server detects that it is on Cloud Run, it
outputs logs in a JSON format that will produce
properly formatted log entries.

Change-Id: Ifed944ad5916f372cac74d7e17b68f2fa32b3d04
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/472276
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index 005f580..0597b1c 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -39,7 +39,13 @@
 
 	flag.Parse()
 	ctx := context.Background()
-	slog.SetDefault(slog.New(log.NewLineHandler(os.Stderr)))
+	var h slog.Handler
+	if config.OnCloudRun() || *devMode {
+		h = log.NewGoogleCloudHandler()
+	} else {
+		h = log.NewLineHandler(os.Stderr)
+	}
+	slog.SetDefault(slog.New(h))
 	if err := runServer(ctx); err != nil {
 		log.Error(ctx, "fail", err)
 		os.Exit(1)
diff --git a/internal/log/cloud_handler.go b/internal/log/cloud_handler.go
new file mode 100644
index 0000000..96ab5f3
--- /dev/null
+++ b/internal/log/cloud_handler.go
@@ -0,0 +1,37 @@
+// Copyright 2023 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 log
+
+import (
+	"os"
+	"time"
+
+	"golang.org/x/exp/slog"
+)
+
+// NewGoogleCloudHandler returns a Handler that outputs JSON for the Google
+// Cloud logging service.
+// See https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
+// for treatment of special fields.
+func NewGoogleCloudHandler() slog.Handler {
+	return slog.HandlerOptions{ReplaceAttr: gcpReplaceAttr}.
+		NewJSONHandler(os.Stderr)
+}
+
+func gcpReplaceAttr(groups []string, a slog.Attr) slog.Attr {
+	switch a.Key {
+	case "time":
+		if a.Value.Kind() == slog.KindTime {
+			a.Value = slog.StringValue(a.Value.Time().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/log/gcpjson.go b/internal/log/gcpjson.go
deleted file mode 100644
index 1deaba6..0000000
--- a/internal/log/gcpjson.go
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright 2023 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 log
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"sync"
-	"time"
-
-	"golang.org/x/exp/event"
-)
-
-// NewGCPJSONLogger returns a handler which logs events in a format that is
-// understood by Google Cloud Platform logging.
-func NewGCPJSONHandler(w io.Writer, traceID string) event.Handler {
-	return &gcpJSONHandler{w: w, traceID: traceID}
-}
-
-type gcpJSONHandler struct {
-	traceID string
-	mu      sync.Mutex // ensure a log line is not interrupted
-	w       io.Writer
-}
-
-// Event implements event.Handler.Event.
-// It handles Log events and ignores all others.
-// See https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
-// for treatment of special fields.
-func (h *gcpJSONHandler) Event(ctx context.Context, ev *event.Event) context.Context {
-	if ev.Kind != event.LogKind {
-		return ctx
-	}
-	h.mu.Lock()
-	defer h.mu.Unlock()
-
-	fmt.Fprintf(h.w, `{"time": %q`, ev.At.Format(time.RFC3339))
-	if h.traceID != "" {
-		fmt.Fprintf(h.w, `, "logging.googleapis.com/trace": %q`, h.traceID)
-	}
-	gcpLabels := map[string]string{}
-	for _, l := range ev.Labels {
-		var key string
-		switch l.Name {
-		case "msg":
-			key = "message"
-		case "level":
-			key = "severity"
-		default:
-			gcpLabels[l.Name] = l.String() // already quoted, regardless of label kind
-			continue
-		}
-		fmt.Fprintf(h.w, ", %q: ", key)
-		switch {
-		case !l.HasValue():
-			fmt.Fprint(h.w, "null")
-		case l.IsInt64():
-			fmt.Fprintf(h.w, "%d", l.Int64())
-		case l.IsUint64():
-			fmt.Fprintf(h.w, "%d", l.Uint64())
-		case l.IsFloat64():
-			fmt.Fprintf(h.w, "%g", l.Float64())
-		case l.IsBool():
-			fmt.Fprintf(h.w, "%t", l.Bool())
-		default:
-			fmt.Fprintf(h.w, "%q", l.String())
-		}
-	}
-	if len(gcpLabels) > 0 {
-		fmt.Fprintf(h.w, `, "logging.googleapis.com/labels": {`)
-		first := true
-		for k, v := range gcpLabels {
-			if !first {
-				fmt.Fprint(h.w, ", ")
-			}
-			first = false
-			fmt.Fprintf(h.w, "%q: %q", k, v)
-		}
-		fmt.Fprint(h.w, "}")
-	}
-	fmt.Fprint(h.w, "}\n")
-	return ctx
-}
diff --git a/internal/log/gcpjson_test.go b/internal/log/gcpjson_test.go
deleted file mode 100644
index 2dc7b36..0000000
--- a/internal/log/gcpjson_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2023 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 log
-
-import (
-	"bytes"
-	"context"
-	"testing"
-	"time"
-
-	"golang.org/x/exp/event"
-	"golang.org/x/exp/event/severity"
-)
-
-func TestGCPJSON(t *testing.T) {
-	now := time.Date(2002, 3, 4, 5, 6, 7, 0, time.UTC)
-	for _, test := range []struct {
-		ev   event.Event
-		want string
-	}{
-		{
-			ev: event.Event{
-				At:   now,
-				Kind: event.LogKind,
-				Labels: []event.Label{
-					event.String("msg", "hello"),
-					event.Int64("count", 17),
-					severity.Info.Label(),
-				},
-			},
-			want: `{"time": "2002-03-04T05:06:07Z", "logging.googleapis.com/trace": "tid", "message": "hello", "severity": "info", "logging.googleapis.com/labels": {"count": "17"}}
-`,
-		},
-	} {
-		var buf bytes.Buffer
-		h := &gcpJSONHandler{w: &buf, traceID: "tid"}
-		h.Event(context.Background(), &test.ev)
-		got := buf.String()
-		if got != test.want {
-			t.Errorf("%+v:\ngot  %s\nwant %s", test.ev, got, test.want)
-		}
-	}
-}