├── .gitignore ├── tools.go ├── setup.sh ├── go.mod ├── README.md ├── test.sh ├── .github └── workflows │ └── test.yml ├── LICENSE ├── gcpslog ├── handler_test.go └── handler.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | 3 | build-cmd/ 4 | coverage.txt 5 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package sdlog 5 | 6 | // from https://github.com/golang/go/issues/25922#issuecomment-412992431 7 | 8 | import ( 9 | _ "golang.org/x/lint/golint" 10 | _ "golang.org/x/tools/cmd/goimports" 11 | ) 12 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -eux 2 | 3 | cd `dirname $0` 4 | 5 | go mod download 6 | 7 | # build tools 8 | rm -rf build-cmd/ 9 | mkdir build-cmd 10 | 11 | export GOBIN=`pwd -P`/build-cmd 12 | go install golang.org/x/tools/cmd/goimports 13 | go install golang.org/x/lint/golint 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vvakame/sdlog/v2 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.3 6 | 7 | require ( 8 | cloud.google.com/go/compute/metadata v0.7.0 9 | go.opentelemetry.io/otel/trace v1.36.0 10 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 11 | golang.org/x/tools v0.12.0 12 | ) 13 | 14 | require ( 15 | go.opentelemetry.io/otel v1.36.0 // indirect 16 | golang.org/x/mod v0.12.0 // indirect 17 | golang.org/x/sys v0.33.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloud Logging compatible logger 2 | 3 | this logger help you to emit [special fields in structured payloads](https://cloud.google.com/logging/docs/agent/configuration#special-fields). 4 | You can emit LogEntry compatible format via stdout or stderr. 5 | 6 | ## How to use 7 | 8 | see examples. 9 | 10 | * aelog - [AppEngine log package](https://godoc.org/google.golang.org/appengine/log) compat api. 11 | * buildlog - building block for logging. 12 | * gcpslog - slog handler. 13 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -eux 2 | 3 | cd `dirname $0` 4 | 5 | targets=`find . -type f \( -name '*.go' -and -not -iwholename '*vendor*' -and -not -iwholename '*testdata*' \)` 6 | packages=`go list ./...` 7 | 8 | # Apply tools 9 | export PATH=$(pwd)/build-cmd:$PATH 10 | which goimports golint 11 | goimports -w $targets 12 | for package in $packages 13 | do 14 | go vet $package 15 | done 16 | golint -set_exit_status -min_confidence 0.6 $packages 17 | 18 | go test $packages -count 1 -p 1 -coverpkg=./... -covermode=atomic -coverprofile=coverage.txt $@ 19 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request_target: {} 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: 13 | labels: 14 | - ubuntu-latest 15 | permissions: 16 | contents: read 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 21 | - uses: actions/setup-go@v3 22 | with: 23 | go-version-file: go.mod 24 | cache: true 25 | - name: Prepare dependencies 26 | run: |- 27 | ./setup.sh 28 | - name: Run tests 29 | run: |- 30 | ./test.sh 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Masahiro Wakame 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gcpslog/handler_test.go: -------------------------------------------------------------------------------- 1 | package gcpslog_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "log/slog" 8 | "testing" 9 | 10 | "github.com/vvakame/sdlog/v2/gcpslog" 11 | ) 12 | 13 | func TestHandler(t *testing.T) { 14 | t.Parallel() 15 | 16 | ctx := context.Background() 17 | ho := &gcpslog.HandlerOptions{ 18 | Level: slog.LevelDebug, 19 | ProjectID: "sdlog-test-project", 20 | TraceInfo: func(ctx context.Context) (string, string) { 21 | return "trace-id-a", "span-id-b" 22 | }, 23 | } 24 | 25 | var buf bytes.Buffer 26 | h := ho.NewHandler(&buf) 27 | 28 | logger := slog.New(h) 29 | logger.Enabled(ctx, slog.LevelDebug) 30 | logger.InfoContext(ctx, "info message") 31 | logger.ErrorContext(ctx, "error message", "error", errors.New("error")) 32 | logger.WarnContext(ctx, "warn message", slog.String("time", "2025-06-17T22:00:00Z")) 33 | logger.LogAttrs(ctx, slog.LevelDebug, "log attrs", slog.String("key", "value")) 34 | 35 | t.Log(buf.String()) 36 | } 37 | 38 | func Test_example(t *testing.T) { 39 | defaultLogger := slog.Default() 40 | t.Cleanup(func() { 41 | slog.SetDefault(defaultLogger) 42 | }) 43 | 44 | var buf bytes.Buffer 45 | slog.SetDefault(slog.New(gcpslog.HandlerOptions{}.NewHandler(&buf))) 46 | 47 | ctx := context.Background() 48 | 49 | slog.InfoContext(ctx, "info message") 50 | slog.ErrorContext(ctx, "error message", "error", errors.New("error")) 51 | slog.LogAttrs(ctx, slog.LevelDebug, "log attrs", slog.String("key", "value")) 52 | 53 | t.Log(buf.String()) 54 | } 55 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= 2 | cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 10 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 12 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 13 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 14 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 15 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 16 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 17 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= 18 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= 19 | golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= 20 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 21 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 22 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 23 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 24 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 26 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 27 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 28 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 29 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 30 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 31 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 32 | golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= 33 | golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= 34 | golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= 35 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 36 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 37 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | -------------------------------------------------------------------------------- /gcpslog/handler.go: -------------------------------------------------------------------------------- 1 | package gcpslog 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "runtime" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "cloud.google.com/go/compute/metadata" 15 | "go.opentelemetry.io/otel/trace" 16 | ) 17 | 18 | // spec. https://cloud.google.com/logging/docs/agent/logging/configuration#special-fields 19 | 20 | // HandlerOptions are options for a Cloud Logging compatible handler. 21 | type HandlerOptions struct { 22 | Level slog.Leveler 23 | ProjectID string 24 | TraceInfo func(ctx context.Context) (traceID string, spanID string) 25 | } 26 | 27 | // NewHandler creates a Cloud Logging compatible handler with the given options that writes to w. 28 | func (ho HandlerOptions) NewHandler(w io.Writer) slog.Handler { 29 | if ho.ProjectID == "" { 30 | ho.ProjectID = gcpProjectID() 31 | } 32 | if ho.TraceInfo == nil { 33 | ho.TraceInfo = otelTraceInfo 34 | } 35 | 36 | h := &handler{ 37 | base: slog.NewJSONHandler(w, &slog.HandlerOptions{ 38 | AddSource: false, 39 | Level: ho.Level, 40 | ReplaceAttr: replaceAttrs, 41 | }), 42 | projectID: ho.ProjectID, 43 | traceInfo: ho.TraceInfo, 44 | } 45 | 46 | return h 47 | } 48 | 49 | type handler struct { 50 | base slog.Handler 51 | projectID string 52 | traceInfo func(ctx context.Context) (string, string) 53 | } 54 | 55 | func (h *handler) clone() *handler { 56 | return &handler{ 57 | base: h.base, 58 | projectID: h.projectID, 59 | traceInfo: h.traceInfo, 60 | } 61 | } 62 | 63 | func (h *handler) Enabled(ctx context.Context, level slog.Level) bool { 64 | return h.base.Enabled(ctx, level) 65 | } 66 | 67 | func (h *handler) Handle(ctx context.Context, record slog.Record) error { 68 | if record.PC != 0 { 69 | fs := runtime.CallersFrames([]uintptr{record.PC}) 70 | f, _ := fs.Next() 71 | 72 | // spec: https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry#logentrysourcelocation 73 | record.AddAttrs( 74 | slog.Group( 75 | "logging.googleapis.com/sourceLocation", 76 | slog.String("file", f.File), 77 | slog.String("line", strconv.Itoa(f.Line)), 78 | slog.String("function", f.Function), 79 | ), 80 | ) 81 | } 82 | 83 | traceID, spanID := h.traceInfo(ctx) 84 | if traceID != "" && !strings.Contains(traceID, "/") { 85 | traceID = fmt.Sprintf("projects/%s/traces/%s", h.projectID, traceID) 86 | } 87 | if traceID != "" { 88 | record.AddAttrs(slog.String("logging.googleapis.com/trace", traceID)) 89 | } 90 | if spanID != "" { 91 | record.AddAttrs(slog.String("logging.googleapis.com/spanId", spanID)) 92 | } 93 | 94 | return h.base.Handle(ctx, record) 95 | } 96 | 97 | func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler { 98 | h = h.clone() 99 | h.base = h.base.WithAttrs(attrs) 100 | 101 | return h 102 | } 103 | 104 | func (h *handler) WithGroup(name string) slog.Handler { 105 | h = h.clone() 106 | h.base = h.base.WithGroup(name) 107 | 108 | return h 109 | } 110 | 111 | func gcpProjectID() string { 112 | if v := os.Getenv("GOOGLE_CLOUD_PROJECT"); v != "" { 113 | return v 114 | } 115 | if v, _ := metadata.ProjectID(); v != "" { 116 | return v 117 | } 118 | 119 | return "" 120 | } 121 | 122 | func otelTraceInfo(ctx context.Context) (string, string) { 123 | span := trace.SpanFromContext(ctx) 124 | if span == nil { 125 | return "", "" 126 | } 127 | 128 | return span.SpanContext().TraceID().String(), span.SpanContext().SpanID().String() 129 | } 130 | 131 | func replaceAttrs(groups []string, a slog.Attr) slog.Attr { 132 | if len(groups) > 0 { 133 | return a 134 | } 135 | 136 | switch a.Key { 137 | case slog.TimeKey: 138 | a.Key = "time" 139 | if a.Value.Kind() != slog.KindTime { 140 | return a 141 | } 142 | a.Value = slog.StringValue(a.Value.Time().Format(time.RFC3339Nano)) 143 | case slog.LevelKey: 144 | a.Key = "severity" 145 | level, ok := a.Value.Any().(slog.Level) 146 | if !ok { 147 | level = slog.LevelError 148 | } 149 | switch level { 150 | case slog.LevelDebug: 151 | a.Value = slog.StringValue("DEBUG") 152 | case slog.LevelInfo: 153 | a.Value = slog.StringValue("INFO") 154 | case slog.LevelWarn: 155 | a.Value = slog.StringValue("WARNING") 156 | case slog.LevelError: 157 | a.Value = slog.StringValue("ERROR") 158 | default: 159 | a.Value = slog.StringValue("ERROR") 160 | } 161 | case slog.MessageKey: 162 | a.Key = "message" 163 | case slog.SourceKey: 164 | // nothing to do 165 | default: 166 | // ok 167 | } 168 | 169 | return a 170 | } 171 | --------------------------------------------------------------------------------