blob: 2b05a54922b376c7f57f22f39cd38117a37c8d70 [file] [log] [blame]
// 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 (
"context"
"io"
"log/slog"
"os"
"strings"
"time"
)
// New creates a new [slog.Handler] for GCP logging.
// It follows [GCP's logging specification] by modifying
// slog defaults:
// - It replaces the key "msg" with "message".
// - It replaces the key "level" with "severity".
// - It replaces the key "traceID" with "logging.googleapis.com/trace.".
// - It replaces the value for the "time" key with an RFC3339-formatted string.
//
// It also appends some attributes to the message, because the GCP log viewer
// requires two clicks to get from the main log view to the attributes.
//
// [GCP's logging specification]: https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
func New(level slog.Leveler) slog.Handler {
return newHandler(level, os.Stderr)
}
// The maximum length of a message after appending attributes.
// Var for testing.
var maxMessageLength = 100
// newHandler is for testing.
func newHandler(level slog.Leveler, w io.Writer) slog.Handler {
jh := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: level,
ReplaceAttr: replaceAttr,
})
return &handler{jh}
}
// 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.
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
}
// handler is a [slog.Handler] that appends some attributes
// to the message, but otherwise behaves like its underlying
// handler.
type handler struct {
h slog.Handler
}
func (h *handler) Enabled(ctx context.Context, lvl slog.Level) bool {
return h.h.Enabled(ctx, lvl)
}
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &handler{h: h.h.WithAttrs(attrs)}
}
func (h *handler) WithGroup(name string) slog.Handler {
return &handler{h: h.h.WithGroup(name)}
}
// Handle implements [slog.Handler] by appending the first attributes
// to the message until a limit is reached, then calling the underlying
// handler.
func (h *handler) Handle(ctx context.Context, r slog.Record) error {
var b strings.Builder
b.WriteString(r.Message)
r.Attrs(func(a slog.Attr) bool {
s := a.String()
if b.Len()+1+len(s) > maxMessageLength {
return false
}
b.WriteByte(' ')
b.WriteString(s)
return true
})
r.Message = b.String()
return h.h.Handle(ctx, r)
}