Skip to content

Commit

Permalink
add handler for GCP Cloud Logging
Browse files Browse the repository at this point in the history
  • Loading branch information
ktong committed Feb 20, 2024
1 parent 59d5fe0 commit 7ee1046
Show file tree
Hide file tree
Showing 6 changed files with 489 additions and 19 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added

- Add sampling handler for sampling records at request level (#3).
- Add handler to emit JSON logs to GCP Cloud Logging (#5).
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
Sloth provides opinionated slog handlers for major Cloud providers. It providers following slog handlers:

- [`sampling`](sampling) provides a slog handler for sampling records at request level.
- [`gcp`](gcp) provides a slog handler for emitting JSON logs to GCP Cloud Logging.
300 changes: 300 additions & 0 deletions gcp/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2024 The sloth authors
// Use of this source code is governed by a MIT license found in the LICENSE file.

package gcp

import (
"bytes"
"context"
"encoding/hex"
"log/slog"
"os"
"runtime"
"runtime/debug"
"slices"
"strconv"
"strings"
)

func New(opts ...Option) slog.Handler {
option := &options{}
for _, opt := range opts {
opt(option)
}
if option.writer == nil {
option.writer = os.Stderr
}

var handler slog.Handler
handler = slog.NewJSONHandler(
option.writer,
&slog.HandlerOptions{
AddSource: true,
Level: option.level,
ReplaceAttr: replaceAttr(),
},
)
if option.trace != nil {
option.trace.handler = handler
handler = *option.trace
}
if option.errorReporting != nil {
option.errorReporting.handler = handler
handler = *option.errorReporting
}

return handler
}

func replaceAttr() func([]string, slog.Attr) slog.Attr {
// Replace attributes to match GCP Cloud Logging format.
//
// [attributes]: https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
replacer := map[string]func(slog.Attr) slog.Attr{
// Maps the slog levels to the correct [severity] for GCP Cloud Logging.
//
// [severity]: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#LogSeverity
slog.LevelKey: func(attr slog.Attr) slog.Attr {
var severity string
if level, ok := attr.Value.Any().(slog.Level); ok {
switch {
case level >= slog.LevelError:
severity = "ERROR"
case level >= slog.LevelWarn:
severity = "WARNING"
case level >= slog.LevelInfo:
severity = "INFO"
default:
severity = "DEBUG"
}
}

return slog.String("severity", severity)
},
slog.SourceKey: func(attr slog.Attr) slog.Attr {
attr.Key = "logging.googleapis.com/sourceLocation"

return attr
},
slog.MessageKey: func(attr slog.Attr) slog.Attr {
attr.Key = "message"

return attr
},
// Format event [timestamp] according to GCP JSON formats.
//
// [timestamp]: https://cloud.google.com/logging/docs/agent/logging/configuration#timestamp-processing
slog.TimeKey: func(attr slog.Attr) slog.Attr {
time := attr.Value.Time()

return slog.Group("timestamp",
slog.Int64("seconds", time.Unix()),
slog.Int64("nanos", int64(time.Nanosecond())),
)
},
}

return func(groups []string, attr slog.Attr) slog.Attr {
if len(groups) > 0 {
return attr
}

if replace, ok := replacer[attr.Key]; ok {
return replace(attr)
}

return attr
}
}

type TraceContext interface {
TraceID() [16]byte
SpanID() [8]byte
TraceFlags() byte
}

type traceHandler struct {
handler slog.Handler
groupAttrs groupAttrs

project string
contextProvider func(context.Context) TraceContext
}

func (h traceHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}

func (h traceHandler) Handle(ctx context.Context, record slog.Record) error {
const sampled = 0x1

handler := h.handler.WithAttrs([]slog.Attr{h.groupAttrs.attr()})
if traceContext := h.contextProvider(ctx); traceContext.TraceID() != [16]byte{} {
traceID := traceContext.TraceID()
spanID := traceContext.SpanID()
traceFlags := traceContext.TraceFlags()
handler = handler.WithAttrs([]slog.Attr{
slog.String("logging.googleapis.com/trace", "projects/"+h.project+"/traces/"+hex.EncodeToString(traceID[:])),
slog.String("logging.googleapis.com/spanId", hex.EncodeToString(spanID[:])),
slog.Bool("logging.googleapis.com/trace_sampled", traceFlags&sampled == sampled),
})
}
for _, group := range h.groupAttrs.groups {
handler = handler.WithGroup(group.name)
}

return handler.Handle(ctx, record)
}

func (h traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(h.groupAttrs.groups) == 0 {
h.handler = h.handler.WithAttrs(attrs)
} else {
h.groupAttrs = h.groupAttrs.withAttrs(attrs)
}

return h
}

func (h traceHandler) WithGroup(name string) slog.Handler {
h.groupAttrs = h.groupAttrs.withGroup(name)

return h
}

type errorReportingHandler struct {
handler slog.Handler
groupAttrs groupAttrs

service string
version string
}

func (h errorReportingHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.handler.Enabled(ctx, level)
}

func (h errorReportingHandler) Handle(ctx context.Context, record slog.Record) error {
// Format log to report [error events].
//
// [error events]: https://cloud.google.com/error-reporting/docs/formatting-error-messages
handler := h.handler.WithAttrs([]slog.Attr{h.groupAttrs.attr()})
if record.Level >= slog.LevelError {
firstFrame, _ := runtime.CallersFrames([]uintptr{record.PC}).Next()
handler = handler.WithAttrs(
[]slog.Attr{
slog.Group("context",
slog.Group("reportLocation",
slog.String("filePath", firstFrame.File),
slog.Int("lineNumber", firstFrame.Line),
slog.String("functionName", firstFrame.Function),
),
),
slog.Group("serviceContext",
slog.String("service", h.service),
slog.String("version", h.version),
),
slog.String("stack_trace", stack(record.Message, firstFrame)),
})
}
for _, group := range h.groupAttrs.groups {
handler = handler.WithGroup(group.name)
}

return handler.Handle(ctx, record)
}

func stack(message string, firstFrame runtime.Frame) string {
stackTrace := &strings.Builder{}
stackTrace.WriteString(message)
stackTrace.WriteString("\n\n")

frames := bytes.NewBuffer(debug.Stack())
// Always add the first line (goroutine line) in stack trace.
firstLine, err := frames.ReadBytes('\n')
stackTrace.Write(firstLine)
if err != nil {
return stackTrace.String()
}

// Each frame has 2 lines in stack trace, first line is the function and second line is the file:#line.
firstFuncLine := []byte(firstFrame.Function)
firstFileLine := []byte(firstFrame.File + ":" + strconv.Itoa(firstFrame.Line))
var functionLine, fileLine []byte
for {
if functionLine, err = frames.ReadBytes('\n'); err != nil {
break
}
if fileLine, err = frames.ReadBytes('\n'); err != nil {
break
}
if bytes.Contains(functionLine, firstFuncLine) && bytes.Contains(fileLine, firstFileLine) {
stackTrace.Write(functionLine)
stackTrace.Write(fileLine)
_, _ = frames.WriteTo(stackTrace)

break
}
}

return stackTrace.String()
}

func (h errorReportingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(h.groupAttrs.groups) == 0 {
h.handler = h.handler.WithAttrs(attrs)
} else {
h.groupAttrs = h.groupAttrs.withAttrs(attrs)
}

return h
}

func (h errorReportingHandler) WithGroup(name string) slog.Handler {
h.groupAttrs = h.groupAttrs.withGroup(name)

return h
}

type (
groupAttrs struct {
groups []group
}

group struct {
name string
attrs []slog.Attr
}
)

func (g groupAttrs) withAttrs(attrs []slog.Attr) groupAttrs {
g.groups = slices.Clone(g.groups)
if len(g.groups) == 0 {
g.groups = append(g.groups, group{})
}
g.groups[len(g.groups)-1].attrs = slices.Concat(g.groups[len(g.groups)-1].attrs, attrs)

Check failure on line 275 in gcp/handler.go

View workflow job for this annotation

GitHub Actions / Test (oldstable)

undefined: slices.Concat

return g
}

func (g groupAttrs) withGroup(name string) groupAttrs {
g.groups = slices.Clone(g.groups)
g.groups = append(g.groups, group{name: name})

return g
}

func (g groupAttrs) attr() slog.Attr {
var attr slog.Attr
for i := len(g.groups) - 1; i >= 0; i-- {
group := g.groups[i]
attrs := make([]any, 0, len(group.attrs)+1)
attrs = append(attrs, attr)
for _, attr := range group.attrs {
attrs = append(attrs, attr)
}
attr = slog.Group(group.name, attrs...)
}

return attr
}
Loading

0 comments on commit 7ee1046

Please sign in to comment.