Skip to content

Commit

Permalink
add handler for GCP Cloud Logging (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
ktong authored Feb 21, 2024
1 parent f58a8f1 commit 3ccfdbe
Show file tree
Hide file tree
Showing 9 changed files with 553 additions and 22 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: 'stable'
cache-dependency-path: "**/go.sum"
cache: false
- name: Lint
uses: golangci/golangci-lint-action@v4
with:
Expand Down
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 (#6).
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# :water_buffalo: An opinionated minimalist Go application framework
# Opinionated Go slog handlers

![Go Version](https://img.shields.io/github/go-mod/go-version/nil-go/sloth)
[![Go Reference](https://pkg.go.dev/badge/github.com/nil-go/sloth.svg)](https://pkg.go.dev/github.com/nil-go/sloth)
Expand All @@ -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.
275 changes: 275 additions & 0 deletions gcp/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Copyright (c) 2024 The sloth authors
// Use of this source code is governed by a MIT license found in the LICENSE file.

/*
Package gcp provides a handler for emitting log records to [GCP Cloud Logging].
The handler formats records to match [GCP Cloud Logging JSON schema].
It also integrates logs with [GCP Cloud Trace] and [GCP Error Reporting] if enabled.
[GCP Cloud Logging]: https://cloud.google.com/logging
[GCP Cloud Logging JSON schema]: https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
[GCP Cloud Trace]: https://cloud.google.com/trace
[GCP Error Reporting]: https://cloud.google.com/error-reporting
*/
package gcp

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

// New creates a new Handler with the given Option(s).
// The handler formats records to match [GCP Cloud Logging JSON schema].
//
// [GCP Cloud Logging JSON schema]: https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields
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.project != "" || option.service != "" {
handler = logHandler{
handler: handler,
project: option.project, contextProvider: option.contextProvider,
service: option.service, version: option.version,
}
}

return handler
}

func replaceAttr() func([]string, slog.Attr) slog.Attr {
// Replace attributes to match GCP Cloud Logging format.
//
// See: 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.
//
// See: 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.
//
// See: 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 (
logHandler struct {
handler slog.Handler
groups []group

project string
contextProvider func(context.Context) TraceContext

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

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

func (h logHandler) Handle(ctx context.Context, record slog.Record) error { //nolint:cyclop,funlen
handler := h.handler

if len(h.groups) > 0 {
var (
attr slog.Attr
hasAttr bool
)
for i := len(h.groups) - 1; i >= 0; i-- {
grp := h.groups[i]

attrs := make([]any, 0, len(grp.attrs)+1)
if hasAttr {
attrs = append(attrs, attr)
}
for _, attr := range grp.attrs {
attrs = append(attrs, attr)
}
if len(attrs) > 0 {
attr = slog.Group(grp.name, attrs...)
hasAttr = true
}
}
if hasAttr {
handler = handler.WithAttrs([]slog.Attr{attr})
}
}

// Associate logs with a trace and span.
//
// See: https://cloud.google.com/trace/docs/trace-log-integration
if h.project != "" {
const sampled = 0x1

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

// Format log to report error events.
//
// See: https://cloud.google.com/error-reporting/docs/formatting-error-messages
if record.Level >= slog.LevelError && h.service != "" {
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.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 logHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(h.groups) == 0 {
h.handler = h.handler.WithAttrs(attrs)

return h
}

h.groups = slices.Clone(h.groups)
h.groups[len(h.groups)-1].attrs = slices.Clone(h.groups[len(h.groups)-1].attrs)
h.groups[len(h.groups)-1].attrs = append(h.groups[len(h.groups)-1].attrs, attrs...)

return h
}

func (h logHandler) WithGroup(name string) slog.Handler {
h.groups = slices.Clone(h.groups)
h.groups = append(h.groups, group{name: name})

return h
}
Loading

0 comments on commit 3ccfdbe

Please sign in to comment.