diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..ea9e773 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,25 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +ignore: + # All 'pb.go's. + - "**/*.pb.go" + # Tests and test related files. + - "**/test" + - "**/testdata" + - "**/testutils" + - "benchmark" + - "interop" + # Other submodules. + - "cmd" + - "examples" + - "gcp" + - "security" + - "stats/opencensus" +comment: + layout: "header, diff, files" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a078332 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8a58d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: Workflow for CI +on: [ push, pull_request ] +jobs: + run: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.21', '1.22.x' ] + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + fetch-depth: 0 + + - name: Setup Go ${{ matrix.go-version }} + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version: ${{ matrix.go-version }} + + - name: Ensure the Go module is nice and tidy + run: | + go mod tidy && git diff --exit-code go.mod go.sum + # We set the shell explicitly, here, and in other golang test actions, + # as by default multi-line shell scripts do not error out on the first + # failed command. Since we want an error reported if any of the lines + # fail, we set the shell explicitly: + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions?ref=cloudtechsimplified.com#exit-codes-and-error-action-preference + shell: bash + + - name: Install Tools + run: | + pushd "$(mktemp -d)" + go mod init example.com/m # fake module + go install github.com/onsi/ginkgo/v2/ginkgo@v2.17.0 + go install honnef.co/go/tools/cmd/staticcheck@2023.1.6 + popd + shell: bash + + - name: Verify Go Modules Setup + run: go mod verify + shell: bash + + - name: Build + run: go build -v ./... + shell: bash + + - name: Sanity Check (staticcheck) + run: staticcheck ./... + shell: bash + + - name: Test + run: ginkgo -v -race -coverprofile=coverage.out -coverpkg=./... ./... + shell: bash + + - name: Run tests and collect coverage + run: pytest --cov app ${{ env.CODECOV_ATS_TESTS }} + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: maguro/gslog + flags: smart-tests + verbose: true + diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..314c8ac --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,37 @@ +name: "CodeQL" + +on: + push: + branches: [ master ] + schedule: + - cron: '24 20 * * 3' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + security-events: write + pull-requests: read + actions: read + + strategy: + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@928ff8c822d966a999092a6a35e32177899afb7c # v2.24.6 + with: + languages: go + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@928ff8c822d966a999092a6a35e32177899afb7c # v2.24.6 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..46ba060 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,23 @@ +name: Workflow for Codecov +on: [ push, pull_request ] + +permissions: + contents: read + +jobs: + upload: + runs-on: ubuntu-latest + steps: + - name: Install checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - name: Install checkout + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version: "stable" + + - name: Run coverage + run: go test -coverprofile=coverage.out -coverpkg=./... ./... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457 # v3.1.6 diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..b0d3b9a --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,77 @@ +name: Testing + +# Trigger on pushes, PRs (excluding documentation changes), and nightly. +on: + push: + pull_request: + schedule: + - cron: 0 0 * * * # daily at 00:00 + +permissions: + contents: read + +# Always force the use of Go modules +env: + GO111MODULE: on + +jobs: + + # Run the main gRPC-Go tests. + tests: + # Proto checks are run in the above job. + env: + VET_SKIP_PROTO: 1 + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - type: tests + goversion: '1.22' + + - type: tests + goversion: '1.22' + testflags: -race + + - type: tests + goversion: '1.22' + goarch: 386 + + - type: tests + goversion: '1.22' + goarch: arm64 + + - type: tests + goversion: '1.21' + steps: + # Setup the environment. + - name: Setup GOARCH + if: matrix.goarch != '' + run: echo "GOARCH=${{ matrix.goarch }}" >> $GITHUB_ENV + + - name: Setup qemu emulator + if: matrix.goarch == 'arm64' + # setup qemu-user-static emulator and register it with binfmt_misc so that aarch64 binaries + # are automatically executed using qemu. + run: docker run --rm --privileged multiarch/qemu-user-static:5.2.0-2 --reset --credential yes --persistent yes + + - name: Setup GRPC environment + if: matrix.grpcenv != '' + run: echo "${{ matrix.grpcenv }}" >> $GITHUB_ENV + + - name: Setup Go + uses: actions/setup-go@0c52d547c9bc32b1aa3301fd7a9cb496313a4491 # v5.0.0 + with: + go-version: ${{ matrix.goversion }} + + - name: Checkout repo + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + # Main tests run for everything except when testing "extras" + # (where we run a reduced set of tests). + - name: Run tests + if: matrix.type == 'tests' + run: | + go version + go test ${{ matrix.testflags }} -cpu 1,4 -timeout 7m m4o.io/gslog/... diff --git a/README.md b/README.md index 64f752c..54f8615 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # gslog -An slog Handler for Google Cloud Logging + +[![Documentation](https://godoc.org/github.com/maguro/gslog?status.svg)](http://godoc.org/github.com/maguro/gslog) +[![Go Report Card](https://goreportcard.com/badge/github.com/maguro/gslog)](https://goreportcard.com/report/github.com/maguro/gslog) +[![codecov](https://codecov.io/gh/maguro/gslog/graph/badge.svg?token=3FAJJ2SIZB)](https://codecov.io/gh/maguro/gslog) + +A Google Cloud Logging [Handler](https://pkg.go.dev/log/slog#Handler) implementation for [slog](https://go.dev/blog/slog). diff --git a/attr.go b/attr.go new file mode 100644 index 0000000..aac0651 --- /dev/null +++ b/attr.go @@ -0,0 +1,44 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "log/slog" +) + +// AttrMapper is called to rewrite each non-group attribute before it is logged. +// The attribute's value has been resolved (see [Value.Resolve]). +// If replaceAttr returns a zero Attr, the attribute is discarded. +// +// The built-in attribute with key "message" is passed to this function. +// +// The first argument is a list of currently open groups that contain the +// Attr. It must not be retained or modified. replaceAttr is never called +// for Group attributes, only their contents. For example, the attribute +// list +// +// Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) +// +// results in consecutive calls to replaceAttr with the following arguments: +// +// nil, Int("a", 1) +// []string{"g"}, Int("b", 2) +// nil, Int("c", 3) +// +// AttrMapper can be used to change the default keys of the built-in +// attributes, convert types (for example, to replace a `time.Time` with the +// integer seconds since the Unix epoch), sanitize personal information, or +// remove attributes from the output. +type AttrMapper func(groups []string, a slog.Attr) slog.Attr diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b1ba113 --- /dev/null +++ b/doc.go @@ -0,0 +1,16 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gslog contains a GCP logging implementation of slog.Handler. +package gslog diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..769f4c5 --- /dev/null +++ b/go.mod @@ -0,0 +1,47 @@ +module m4o.io/gslog + +go 1.21 + +require ( + cloud.google.com/go/logging v1.9.0 + github.com/golang/protobuf v1.5.3 + github.com/magiconair/properties v1.8.7 + github.com/onsi/ginkgo/v2 v2.17.1 + github.com/onsi/gomega v1.32.0 + github.com/stretchr/testify v1.9.0 + go.opentelemetry.io/otel/trace v1.24.0 + google.golang.org/protobuf v1.33.0 +) + +require ( + cloud.google.com/go v0.110.8 // indirect + cloud.google.com/go/compute v1.23.1 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/longrunning v0.5.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/oauth2 v0.13.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect + google.golang.org/api v0.149.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect + google.golang.org/grpc v1.59.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1522217 --- /dev/null +++ b/go.sum @@ -0,0 +1,178 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.110.8 h1:tyNdfIxjzaWctIiLYOTalaLKZ17SI44SKFW26QbOhME= +cloud.google.com/go v0.110.8/go.mod h1:Iz8AkXJf1qmxC3Oxoep8R1T36w8B92yU29PcBhHO5fk= +cloud.google.com/go/compute v1.23.1 h1:V97tBoDaZHb6leicZ1G6DLK2BAaZLJ/7+9BB/En3hR0= +cloud.google.com/go/compute v1.23.1/go.mod h1:CqB3xpmPKKt3OJpW2ndFIXnA9A4xAy/F3Xp1ixncW78= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/iam v1.1.3 h1:18tKG7DzydKWUnLjonWcJO6wjSCAtzh4GcRKlH/Hrzc= +cloud.google.com/go/logging v1.9.0 h1:iEIOXFO9EmSiTjDmfpbRjOxECO7R8C7b8IXUGOj7xZw= +cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= +cloud.google.com/go/longrunning v0.5.2 h1:u+oFqfEwwU7F9dIELigxbe0XVnBAo9wqMuQLA50CZ5k= +cloud.google.com/go/longrunning v0.5.2/go.mod h1:nqo6DQbNV2pXhGDbDMoN2bWz68MjZUzqv2YttZiveCs= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= +github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= +golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.149.0 h1:b2CqT6kG+zqJIVKRQ3ELJVLN1PwHZ6DJ3dW8yl82rgY= +google.golang.org/api v0.149.0/go.mod h1:Mwn1B7JTXrzXtnvmzQE2BD6bYZQ8DShKZDZbeN9I7qI= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= +google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:CgAqfJo+Xmu0GwA0411Ht3OU3OntXwsGmrmjI8ioGXI= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= +google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b h1:ZlWIi1wSK56/8hn4QcBp/j9M7Gt3U/3hZw3mC7vDICo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:swOH3j0KzcDDgGUWr+SNpyTen5YrXjS3eyPzFYKc6lc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/gslog_suite_test.go b/gslog_suite_test.go new file mode 100644 index 0000000..62e61b6 --- /dev/null +++ b/gslog_suite_test.go @@ -0,0 +1,29 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGo(t *testing.T) { + RegisterFailHandler(Fail) + suiteConfig, reporterConfig := GinkgoConfiguration() + reporterConfig.Verbose = true + RunSpecs(t, "GCP Cloud Logging slog Handler Suite", suiteConfig, reporterConfig) +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..e8c98cf --- /dev/null +++ b/handler.go @@ -0,0 +1,244 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" + "fmt" + "log/slog" + "os" + "runtime" + "slices" + + "cloud.google.com/go/logging" + logpb "cloud.google.com/go/logging/apiv2/loggingpb" + structpb "github.com/golang/protobuf/ptypes/struct" + "google.golang.org/protobuf/proto" + spb "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog/internal/attr" + "m4o.io/gslog/internal/level" + "m4o.io/gslog/internal/options" +) + +const ( + MessageKey = "message" +) + +// GcpHandler is a Google Cloud Logging backed slog handler. +type GcpHandler struct { + // *logging.Logger, except for testing + log Logger + level slog.Leveler + + // addSource causes the handler to compute the source code position + // of the log statement and add a SourceKey attribute to the output. + addSource bool + entryAugmentors []func(ctx context.Context, e *logging.Entry) + replaceAttr AttrMapper + + payload *structpb.Struct + groups []string +} + +var _ slog.Handler = &GcpHandler{} + +// NewGcpHandler creates a Google Cloud Logging backed log.Logger. +func NewGcpHandler(logger Logger, opts ...options.OptionProcessor) *GcpHandler { + if logger == nil { + panic("client is nil") + } + o := options.ApplyOptions(opts...) + + return newGcpLoggerWithOptions(logger, o) +} + +func newGcpLoggerWithOptions(logger Logger, o *options.Options) *GcpHandler { + h := &GcpHandler{ + log: logger, + level: o.Level, + + addSource: o.AddSource, + entryAugmentors: o.EntryAugmentors, + replaceAttr: attr.WrapAttrMapper(o.ReplaceAttr), + + payload: &structpb.Struct{Fields: make(map[string]*structpb.Value)}, + } + + return h +} + +// WithLeveler returns a copy of the handler, provisioned with the supplied +// leveler. +func (h *GcpHandler) WithLeveler(leveler slog.Leveler) *GcpHandler { + if leveler == nil { + panic("Leveler is nil") + } + + h2 := h.clone() + h2.level = leveler + + return h2 +} + +func (h *GcpHandler) Enabled(_ context.Context, level slog.Level) bool { + return h.level.Level() <= level +} + +// Handle will handle a slog.Record, as described in the interface's +// documentation. It will translate the slog.Record into a logging.Entry +// that's filled with a *structpb.Value as a Entry Payload. +func (h *GcpHandler) Handle(ctx context.Context, record slog.Record) error { + payload2 := proto.Clone(h.payload).(*structpb.Struct) + if payload2.Fields == nil { + payload2.Fields = make(map[string]*structpb.Value) + } + + setAndClean(h.groups, payload2, func(groups []string, payload *structpb.Struct) { + record.Attrs(func(a slog.Attr) bool { + if h.replaceAttr != nil { + a = h.replaceAttr(h.groups, a) + } + attr.DecorateWith(payload, a) + return true + }) + }) + + msg := record.Message + a := slog.String(MessageKey, msg) + if h.replaceAttr != nil { + a = h.replaceAttr(nil, a) + } + attr.DecorateWith(payload2, a) + + var e logging.Entry + + e.Payload = payload2 + e.Timestamp = record.Time.UTC() + e.Severity = level.LevelToSeverity(record.Level) + e.Labels = ExtractLabels(ctx) + + if h.addSource { + addSourceLocation(&e, &record) + } + + for _, b := range h.entryAugmentors { + b(ctx, &e) + } + + if e.Severity >= logging.Critical { + err := h.log.LogSync(ctx, e) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error logging: %s\n%s", record.Message, err) + } + } else { + h.log.Log(e) + } + + return nil +} + +func (h *GcpHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + var h2 = h.clone() + + current := fromPath(h2.payload, h2.groups) + + for _, a := range attrs { + if h.replaceAttr != nil { + a = h.replaceAttr(h.groups, a) + } + attr.DecorateWith(current, a) + } + + return h2 +} + +func (h *GcpHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + var h2 = h.clone() + + h2.payload = proto.Clone(h.payload).(*structpb.Struct) + + current := fromPath(h2.payload, h2.groups) + + current.Fields[name] = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: make(map[string]*structpb.Value), + }, + }, + } + + h2.groups = append(h.groups, name) + + return h2 +} + +func (h *GcpHandler) clone() *GcpHandler { + return &GcpHandler{ + log: h.log, + level: h.level, + + addSource: h.addSource, + entryAugmentors: h.entryAugmentors, + replaceAttr: h.replaceAttr, + + payload: proto.Clone(h.payload).(*structpb.Struct), + groups: slices.Clip(h.groups), + } +} + +func addSourceLocation(e *logging.Entry, r *slog.Record) { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + + e.SourceLocation = &logpb.LogEntrySourceLocation{ + File: f.File, + Line: int64(f.Line), + Function: f.Function, + } +} + +func fromPath(p *structpb.Struct, path []string) *structpb.Struct { + for _, k := range path { + p = p.Fields[k].GetStructValue() + } + if p.Fields == nil { + p.Fields = make(map[string]*structpb.Value) + } + return p +} + +func setAndClean(groups []string, payload *structpb.Struct, decorate func(groups []string, payload *structpb.Struct)) { + if len(groups) == 0 { + if payload.Fields == nil { + payload.Fields = make(map[string]*spb.Value) + } + + decorate(groups, payload) + return + } + + g := groups[0] + + s := payload.Fields[g].GetStructValue() + setAndClean(groups[1:], s, decorate) + + if len(s.Fields) == 0 { + delete(payload.Fields, g) + } +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..36a0a1d --- /dev/null +++ b/handler_test.go @@ -0,0 +1,1169 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "encoding/json" + "log/slog" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "cloud.google.com/go/logging" + logpb "cloud.google.com/go/logging/apiv2/loggingpb" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog" + "m4o.io/gslog/internal/attr" + "m4o.io/gslog/internal/options" +) + +var testTime = time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC) + +type replace struct { + v slog.Value +} + +func (r *replace) LogValue() slog.Value { return r.v } + +type Got struct { + LogEntry logging.Entry + SyncLogEntry logging.Entry +} + +func (g *Got) Log(e logging.Entry) { + g.LogEntry = e +} + +func (g *Got) LogSync(_ context.Context, e logging.Entry) error { + g.SyncLogEntry = e + return nil +} + +// callerPC returns the program counter at the given stack depth. +func callerPC(depth int) uintptr { + var pcs [1]uintptr + runtime.Callers(depth, pcs[:]) + return pcs[0] +} + +func TestDefaultHandle(t *testing.T) { + ctx := context.Background() + preAttrs := []slog.Attr{slog.Int("pre", 0)} + attrs := []slog.Attr{slog.Int("a", 1), slog.String("b", "two")} + for _, test := range []struct { + name string + with func(slog.Handler) slog.Handler + attrs []slog.Attr + want func() logging.Entry + }{ + { + name: "no attrs", + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "attrs", + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.String("b", "two")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 0)) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.String("b", "two")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "groups", + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4)), + slog.Int("e", 5), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4))) + attr.DecorateWith(p, slog.Int("e", 5)) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "group", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs).WithGroup("s") }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 0)) + attr.DecorateWith(p, slog.Group("s", + slog.Int("a", 1), + slog.String("b", "two"))) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted groups", + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithAttrs([]slog.Attr{slog.Int("p2", 2)}). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Int("p2", 2), + slog.Group("s2", + slog.Int("a", 1), + slog.String("b", "two"), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "two with-groups", + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Group("s2", + slog.Int("a", 1), + slog.String("b", "two"), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + got := &Got{} + var h slog.Handler = gslog.NewGcpHandler(got, gslog.WithDefaultLogLevel(slog.LevelInfo)) + if test.with != nil { + h = test.with(h) + } + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "message", 0) + r.AddAttrs(test.attrs...) + if err := h.Handle(ctx, r); err != nil { + t.Fatal(err) + } + + want := test.want() + assert.Equal(t, want.Timestamp, got.LogEntry.Timestamp) + assert.Equal(t, want.Severity, got.LogEntry.Severity) + assert.True(t, proto.Equal(want.Payload.(proto.Message), got.LogEntry.Payload.(proto.Message))) + }) + } +} + +func TestConcurrentWrites(t *testing.T) { + const count = 1000 + + var mu sync.Mutex + var s1Count int + var s2Count int + var h slog.Handler = gslog.NewGcpHandler( + gslog.LoggerFn(func(e logging.Entry) { + mu.Lock() + defer mu.Unlock() + + p := e.Payload.(*structpb.Struct) + if _, ok := p.Fields["sub1"]; ok { + s1Count++ + } + if _, ok := p.Fields["sub2"]; ok { + s2Count++ + } + }), + gslog.WithDefaultLogLevel(slog.LevelInfo)) + + sub1 := h.WithAttrs([]slog.Attr{slog.Bool("sub1", true)}) + sub2 := h.WithAttrs([]slog.Attr{slog.Bool("sub2", true)}) + + ctx := context.Background() + var wg sync.WaitGroup + for i := 0; i < count; i++ { + sub1Record := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello from sub1", 0) + sub1Record.AddAttrs(slog.Int("i", i)) + sub2Record := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello from sub2", 0) + sub2Record.AddAttrs(slog.Int("i", i)) + + wg.Add(1) + + go func() { + defer wg.Done() + if err := sub1.Handle(ctx, sub1Record); err != nil { + t.Error(err) + } + if err := sub2.Handle(ctx, sub2Record); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + + assert.Equal(t, count, s1Count) + assert.Equal(t, count, s2Count) +} + +// Verify the common parts of TextHandler and JSONHandler. +func TestJSONAndTextHandlers(t *testing.T) { + // remove all Attrs + removeAll := func(_ []string, a slog.Attr) slog.Attr { return slog.Attr{} } + + attrs := []slog.Attr{slog.String("a", "one"), slog.Int("b", 2), slog.Any("", nil)} + preAttrs := []slog.Attr{slog.Int("pre", 3), slog.String("x", "y")} + + for _, test := range []struct { + name string + replace func([]string, slog.Attr) slog.Attr + addSource *logpb.LogEntrySourceLocation + with func(slog.Handler) slog.Handler + preAttrs []slog.Attr + attrs []slog.Attr + want func() logging.Entry + }{ + { + name: "basic", + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty key", + attrs: append(slices.Clip(attrs), slog.Any("", "v")), + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + attr.DecorateWith(p, slog.Any("", "v")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "cap keys", + replace: upperCaseKey, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("MESSAGE", "message")) + attr.DecorateWith(p, slog.String("A", "one")) + attr.DecorateWith(p, slog.Int("B", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "remove all", + replace: removeAll, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted cap keys", + replace: upperCaseKey, + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("MESSAGE", "message")) + attr.DecorateWith(p, slog.Int("PRE", 3)) + attr.DecorateWith(p, slog.String("X", "y")) + attr.DecorateWith(p, slog.String("A", "one")) + attr.DecorateWith(p, slog.Int("B", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted remove all", + replace: removeAll, + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "remove built-in", + replace: removeKeys(gslog.MessageKey), + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted remove built-in", + replace: removeKeys(gslog.MessageKey), + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "groups", + replace: removeKeys(), // to simplify the result + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4)), + slog.Int("e", 5), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4))) + attr.DecorateWith(p, slog.Int("e", 5)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty group", + replace: removeKeys(), + attrs: []slog.Attr{slog.Group("g"), slog.Group("h", slog.Int("a", 1))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("h", + slog.Int("a", 1))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "nested empty group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j"))), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "nested non-empty group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j", slog.Int("a", 1)))), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j", slog.Int("a", 1))))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "escapes", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.String("a b", "x\t\n\000y"), + slog.Group(" b.c=\"\\x2E\t", + slog.String("d=e", "f.g\""), + slog.Int("m.d", 1)), // dot is not escaped + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a b", "x\t\n\000y")) + attr.DecorateWith(p, slog.Group(" b.c=\"\\x2E\t", + slog.String("d=e", "f.g\""), + slog.Int("m.d", 1))) // dot is not escaped + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "LogValuer", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Any("name", logValueName{"Ren", "Hoek"}), + slog.Int("b", 2), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Any("name", logValueName{"Ren", "Hoek"})) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + // Test resolution when there is no ReplaceAttr function. + name: "resolve", + attrs: []slog.Attr{ + slog.Any("", &replace{slog.Value{}}), // should be elided + slog.Any("name", logValueName{"Ren", "Hoek"}), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Any("name", logValueName{"Ren", "Hoek"})) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "with-group", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs).WithGroup("s") }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.Group("s", + slog.String("a", "one"), + slog.Int("b", 2))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithAttrs([]slog.Attr{slog.Int("p2", 2)}). + WithGroup("s2"). + WithAttrs([]slog.Attr{slog.Int("p3", 3)}) + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Int("p2", 2), + slog.Group("s2", + slog.Int("p3", 3), + slog.String("a", "one"), + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "two with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Group("s2", + slog.String("a", "one"), + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithGroup("y") + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "with-group empty", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("").WithGroup("y").WithAttrs([]slog.Attr{slog.String("a", "one")}) + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("y", slog.String("a", "one"))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty with-groups, no non-empty attrs", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithAttrs([]slog.Attr{slog.Group("g")}).WithGroup("y") + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "one empty with-group", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("y") + }, + attrs: []slog.Attr{slog.Group("g", slog.Group("h"))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("x", + slog.Int("a", 1), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "GroupValue as slog.Attr value", + replace: removeKeys(), + attrs: []slog.Attr{{"v", slog.AnyValue(slog.IntValue(3))}}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("v", 3)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "byte slice", + replace: removeKeys(), + attrs: []slog.Attr{slog.Any("bs", []byte{1, 2, 3, 4})}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("bs", "AQIDBA==")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "json.RawMessage", + replace: removeKeys(), + attrs: []slog.Attr{slog.Any("bs", json.RawMessage([]byte("1234")))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("bs", 1234)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "inline group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("", slog.Int("b", 2), slog.Int("c", 3)), + slog.Int("d", 4), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Int("b", 2)) + attr.DecorateWith(p, slog.Int("c", 3)) + attr.DecorateWith(p, slog.Int("d", 4)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "Source", + replace: func(gs []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + s := a.Value.Any().(*slog.Source) + s.File = filepath.Base(s.File) + return slog.Any(a.Key, s) + } + return removeKeys()(gs, a) + }, + addSource: &logpb.LogEntrySourceLocation{ + File: "gslog/handler_test.go", + Line: 1, + Function: "m4o.io/gslog_test.TestJSONAndTextHandlers", + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty", + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("g", slog.Int("a", 1))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 1", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("h", slog.Int("b", 2))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 2", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 3", + with: func(h slog.Handler) slog.Handler { return h.WithGroup("g") }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Int("a", 1)}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty inline", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 1", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Group("h", + slog.Int("b", 2), + slog.Group("i", + slog.Int("c", 3)), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 2", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithAttrs([]slog.Attr{slog.Int("n", 4)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a", "b")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Int("n", 4), + slog.Group("h", + slog.Group("i", + slog.Int("c", 3)), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 3", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g"). + WithAttrs([]slog.Attr{slog.Int("x", 0)}). + WithAttrs([]slog.Attr{slog.Int("a", 1)}). + WithAttrs([]slog.Attr{slog.Int("n", 4)}). + WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a", "c")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Int("x", 0), + slog.Int("n", 4), + slog.Group("h", + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace resolved group", + replace: func(groups []string, a slog.Attr) slog.Attr { + if a.Value.Kind() == slog.KindGroup { + return slog.Attr{"bad", slog.IntValue(1)} + } + return removeKeys(gslog.MessageKey)(groups, a) + }, + attrs: []slog.Attr{slog.Any("name", logValueName{"Perry", "Platypus"})}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("name", + slog.String("first", "Perry"), + slog.String("last", "Platypus"), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + r := slog.NewRecord(testTime, slog.LevelInfo, "message", callerPC(2)) + line := source(r).Line + r.AddAttrs(test.attrs...) + + var opts = []options.OptionProcessor{ + gslog.WithReplaceAttr(test.replace), + gslog.WithDefaultLogLevel(slog.LevelInfo), + } + + if test.addSource != nil { + opts = append(opts, gslog.WithSourceAdded()) + } + + got := &Got{} + var h slog.Handler = gslog.NewGcpHandler(got, opts...) + + if test.with != nil { + h = test.with(h) + } + + if err := h.Handle(context.Background(), r); err != nil { + t.Fatal(err) + } + + if test.want == nil { + return + } + want := test.want() + assert.Equal(t, want.Timestamp, got.LogEntry.Timestamp) + assert.Equal(t, want.Severity, got.LogEntry.Severity) + assert.True(t, proto.Equal(want.Payload.(proto.Message), got.LogEntry.Payload.(proto.Message))) + + if test.addSource != nil { + actual := got.LogEntry.SourceLocation + expected := test.addSource + assert.Equal(t, int64(line), actual.Line) + assert.Equal(t, expected.File, actual.File[len(actual.File)-len(expected.File):]) + assert.Equal(t, expected.Function, actual.Function[0:len(expected.Function)]) + } + }) + } +} + +func TestWithLeveler(t *testing.T) { + got := &Got{} + var h = gslog.NewGcpHandler(got, gslog.WithLogLevel(slog.LevelInfo)) + + l := slog.New(h.WithLeveler(slog.LevelError)) + + l.Debug("How now brown cow") + + assert.Nil(t, got.LogEntry.Payload) + + l.Error("Ouch!") + + assert.NotNil(t, got.LogEntry.Payload) +} + +func TestLevelCritical(t *testing.T) { + got := &Got{} + var h = gslog.NewGcpHandler(got, gslog.WithLogLevel(slog.LevelInfo)) + l := slog.New(h) + + l.Info("How now brown cow") + + assert.NotNil(t, got.LogEntry.Payload) + assert.Nil(t, got.SyncLogEntry.Payload) + + got.LogEntry = logging.Entry{} + + l.Log(context.Background(), gslog.LevelCritical, "Ouch!") + assert.Nil(t, got.LogEntry.Payload) + assert.NotNil(t, got.SyncLogEntry.Payload) +} + +// removeKeys returns a function suitable for HandlerOptions.ReplaceAttr +// that removes all Attrs with the given keys. +func removeKeys(keys ...string) func([]string, slog.Attr) slog.Attr { + return func(_ []string, a slog.Attr) slog.Attr { + for _, k := range keys { + if a.Key == k { + return slog.Attr{} + } + } + return a + } +} + +func upperCaseKey(_ []string, a slog.Attr) slog.Attr { + a.Key = strings.ToUpper(a.Key) + return a +} + +type logValueName struct { + first, last string +} + +func (n logValueName) LogValue() slog.Value { + return slog.GroupValue( + slog.String("first", n.first), + slog.String("last", n.last)) +} + +func source(r slog.Record) *slog.Source { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + return &slog.Source{ + Function: f.Function, + File: f.File, + Line: f.Line, + } + +} diff --git a/internal/attr/attr.go b/internal/attr/attr.go new file mode 100644 index 0000000..8b894c1 --- /dev/null +++ b/internal/attr/attr.go @@ -0,0 +1,239 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package attr contains code that maps slog.Attr attributes to their +corresponding structpb.Value values. +*/ +package attr + +import ( + "bytes" + "encoding/json" + "log/slog" + "sync" + "time" + + structpb "github.com/golang/protobuf/ptypes/struct" + spb "google.golang.org/protobuf/types/known/structpb" +) + +var ( + timePool = sync.Pool{ + New: func() any { + const prefixLen = len(time.RFC3339Nano) + 1 + b := make([]byte, 0, prefixLen) + return &b + }, + } + + NilValue = &structpb.Value{Kind: &structpb.Value_NullValue{NullValue: structpb.NullValue_NULL_VALUE}} +) + +// WrapAttrMapper will wrap an mapper with empty group checks to ensure they +// are properly elided. +func WrapAttrMapper(mapper func(groups []string, a slog.Attr) slog.Attr) func(groups []string, a slog.Attr) slog.Attr { + if mapper == nil { + return nil + } + + var wrapped func(groups []string, a slog.Attr) slog.Attr + + wrapped = func(groups []string, a slog.Attr) slog.Attr { + if a.Value.Kind() == slog.KindGroup { + var attrs []any + for _, ga := range a.Value.Group() { + ma := wrapped(append(groups, a.Key), ga) + + // elide empty attributes + if ma.Key == "" && ma.Value.Any() == nil { + continue + } + + attrs = append(attrs, ma) + } + + if len(attrs) == 0 { + return slog.Attr{} + } + + return slog.Group(a.Key, attrs...) + } + + return mapper(groups, a) + } + + return wrapped +} + +// DecorateWith will add the attribute to the structpb.Struct's Fields. If the +// attribute cannot be mapped to a structpb.Value, nothing is done. Attributes +// of type slog.AnyAttribute are mapped using the following precedence. +// +// - If of type builtin.error and does not implement json.Marshaler, the +// Error() string is used. +// - If attribute can be simply mappable to a structpb.Value, that value is +// used. +// - If the attribute can be converted into a JSON object, that JSON object is +// translated to its corresponding structpb.Struct. +// - Nothing is done. +func DecorateWith(p *structpb.Struct, a slog.Attr) { + rv := a.Value.Resolve() + if a.Key == "" && rv.Any() == nil { + return + } + val, ok := ValToStruct(rv) + if !ok { + return + } + if a.Key == "" && a.Value.Kind() == slog.KindGroup { + for k, v := range val.GetStructValue().Fields { + p.Fields[k] = v + } + } else { + p.Fields[a.Key] = val + } +} + +func ValToStruct(v slog.Value) (val *structpb.Value, ok bool) { + switch v.Kind() { + case slog.KindString: + return NewStringValue(v.String()), true + case slog.KindInt64: + return NewNumberValue(float64(v.Int64())), true + case slog.KindUint64: + return NewNumberValue(float64(v.Uint64())), true + case slog.KindFloat64: + return NewNumberValue(v.Float64()), true + case slog.KindBool: + return NewBoolValue(v.Bool()), true + case slog.KindDuration: + return NewNumberValue(float64(v.Duration())), true + case slog.KindTime: + return NewTimeValue(v.Time()), true + case slog.KindGroup: + if len(v.Group()) == 0 { + return nil, false + } + return NewGroupValue(v.Group()), true + case slog.KindAny: + a := v.Any() + + // if value is an error, but not a JSON marshaller, return error + _, jm := a.(json.Marshaler) + if err, ok := a.(error); ok && !jm { + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: err.Error()}}, true + } + + // value may be simply mappable to a structpb.Value. + if nv, err := spb.NewValue(a); err == nil { + return nv, true + } + + // try converting to a JSON object + return AsJson(a) + default: + return nil, false + } +} + +func MustValToStruct(v slog.Value) (val *structpb.Value) { + val, ok := ValToStruct(v) + if !ok { + panic("expected everything to be ok") + } + return val +} + +func NewStringValue(str string) *structpb.Value { + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: str}} +} + +func NewNumberValue(val float64) *structpb.Value { + return &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: val}} +} + +func NewBoolValue(b bool) *structpb.Value { + return &structpb.Value{Kind: &structpb.Value_BoolValue{BoolValue: b}} +} + +func NewGroupValue(g []slog.Attr) *structpb.Value { + p := &structpb.Struct{Fields: make(map[string]*spb.Value)} + for _, b := range g { + DecorateWith(p, b) + } + return &structpb.Value{Kind: &structpb.Value_StructValue{StructValue: p}} +} + +func NewTimeValue(t time.Time) *structpb.Value { + return &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: AppendRFC3339Millis(t)}} +} + +// AsJson attempts to convert the attribute a to a corresponding structpb.Value +// by first converted to a JSON object and then mapping that JSON object to a +// corresponding structpb.Value. The function also returns true for ok if the +// attribute can be first converted to JSON before being mapped, and false +// otherwise. +func AsJson(a any) (value *structpb.Value, ok bool) { + if a == nil { + return NilValue, true + } + + a, err := ToJson(a) + if err != nil { + return nil, false + } + + nv, _ := spb.NewValue(a) + + return nv, true +} + +func ToJson(a any) (any, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if err := enc.Encode(a); err != nil { + return nil, err + } + + var result any + _ = json.Unmarshal(buf.Bytes(), &result) + + return result, nil +} + +func AppendRFC3339Millis(t time.Time) string { + ptr := timePool.Get().(*[]byte) + buf := *ptr + buf = buf[0:0] + defer func() { + *ptr = buf + timePool.Put(ptr) + }() + buf = append(buf, byte('"')) + + // Format according to time.RFC3339Nano since it is highly optimized, + // but truncate it to use millisecond resolution. + // Unfortunately, that format trims trailing 0s, so add 1/10 millisecond + // to guarantee that there are exactly 4 digits after the period. + const prefixLen = len("2006-01-02T15:04:05.000") + n := len(buf) + t = t.Truncate(time.Millisecond).Add(time.Millisecond / 10) + buf = t.AppendFormat(buf, time.RFC3339Nano) + buf = append(buf[:n+prefixLen], buf[n+prefixLen+1:]...) // drop the 4th digit + + buf = append(buf, byte('"')) + + return string(buf) +} diff --git a/internal/attr/attr_test.go b/internal/attr/attr_test.go new file mode 100644 index 0000000..70a8cc2 --- /dev/null +++ b/internal/attr/attr_test.go @@ -0,0 +1,318 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package attr_test + +import ( + "errors" + "fmt" + "log/slog" + "math" + "reflect" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog/internal/attr" +) + +type Circular struct { + Self *Circular `json:"self"` +} + +type Manager struct { +} + +type Password string + +func (p Password) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote("")), nil +} + +func (p Password) LogValue() slog.Value { + return pwObfuscated +} + +type User struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password Password `json:"password"` + Age uint8 `json:"age"` + Height float32 `json:"height"` + Engineer bool `json:"engineer"` + Manager *Manager `json:"manager"` +} + +type Chimera struct { + Name string `json:"name"` +} + +func (u *Chimera) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`{"name":"%s"}`, u.Name)), nil +} + +// Error should never be called since +func (u *Chimera) Error() string { + panic("ouch") +} + +var ( + pw = Password("pass-12334") + pwObfuscated = slog.StringValue("") + u = &User{ + ID: "user-12234", + FirstName: "Jan", + LastName: "Doe", + Email: "jan@example.com", + Password: pw, + Age: 32, + Height: 5.91, + Engineer: true, + } + + uJson = map[string]interface{}{ + "id": "user-12234", + "first_name": "Jan", + "last_name": "Doe", + "email": "jan@example.com", + "password": "", + "age": float64(32), + "height": 5.91, + "engineer": true, + "manager": nil, + } + + uStruct *structpb.Value + + uGroup []slog.Attr + + circular *Circular + + chimera = &Chimera{Name: "Pookie Bear"} + cStruct = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": { + Kind: &structpb.Value_StringValue{StringValue: "Pookie Bear"}, + }, + }, + }, + }, + } +) + +func init() { + circular = &Circular{} + circular.Self = circular + + fields := make(map[string]*structpb.Value) + fields["id"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "user-12234"}} + fields["first_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Jan"}} + fields["last_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Doe"}} + fields["email"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "jan@example.com"}} + fields["password"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: ""}} + fields["age"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(32)}} + fields["height"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: 5.91}} + fields["engineer"] = &structpb.Value{Kind: &structpb.Value_BoolValue{BoolValue: true}} + fields["manager"] = attr.NilValue + uStruct = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: fields, + }, + }, + } + + uGroup = append(uGroup, slog.String("id", "user-12234")) + uGroup = append(uGroup, slog.String("first_name", "Jan")) + uGroup = append(uGroup, slog.String("last_name", "Doe")) + uGroup = append(uGroup, slog.String("email", "jan@example.com")) + uGroup = append(uGroup, slog.Any("password", pw)) + uGroup = append(uGroup, slog.Uint64("age", 32)) + uGroup = append(uGroup, slog.Float64("height", 5.91)) + uGroup = append(uGroup, slog.Bool("engineer", true)) + uGroup = append(uGroup, slog.Any("manager", nil)) +} + +func TestToJson(t *testing.T) { + tests := map[string]struct { + attr any + json any + err bool + }{ + "ok": {u, uJson, false}, + "simple": {"cow", "cow", false}, + "error": {circular, nil, true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + json, err := attr.ToJson(tc.attr) + if tc.err { + assert.Error(t, err) + } else { + assert.Equal(t, tc.json, json) + } + }) + } +} + +func TestAsJson(t *testing.T) { + tests := map[string]struct { + attr any + value *structpb.Value + ok bool + }{ + "nil": {nil, attr.NilValue, true}, + "not simple": {u, uStruct, true}, + "error": {circular, nil, false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + value, ok := attr.AsJson(tc.attr) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.value, value) + } + }) + } +} + +func TestValToStruct(t *testing.T) { + now := time.Now().UTC() + tests := map[string]struct { + attr slog.Value + value *structpb.Value + ok bool + }{ + "nil": {slog.AnyValue(nil), attr.NilValue, true}, + "string": {slog.StringValue("how now brown cow"), attr.NewStringValue("how now brown cow"), true}, + "int64": {slog.Int64Value(math.MaxInt64), attr.NewNumberValue(float64(math.MaxInt64)), true}, + "uint64": {slog.Uint64Value(math.MaxUint64), attr.NewNumberValue(float64(math.MaxUint64)), true}, + "float64": {slog.Float64Value(math.MaxFloat64), attr.NewNumberValue(math.MaxFloat64), true}, + "bool true": {slog.BoolValue(true), attr.NewBoolValue(true), true}, + "bool false": {slog.BoolValue(false), attr.NewBoolValue(false), true}, + "duration": {slog.DurationValue(time.Minute * 5), attr.NewNumberValue(float64(time.Minute * 5)), true}, + "time": {slog.TimeValue(now), attr.NewTimeValue(now), true}, + "group": {slog.GroupValue(uGroup...), uStruct, true}, + "group empty": {slog.GroupValue(), nil, false}, + "any LogValuer": {slog.AnyValue(pw), nil, false}, // this should have been transformed earlier via Resolve() + "any resolved LogValuer": {slog.AnyValue(pw).Resolve(), attr.NewStringValue(""), true}, + "any JSON": {slog.AnyValue(u), uStruct, true}, + "any json.Marshaler": {slog.AnyValue(chimera), cStruct, true}, + "any error": {slog.AnyValue(errors.New("ouch")), attr.NewStringValue("ouch"), true}, + "error": {slog.AnyValue(circular), nil, false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + value, ok := attr.ValToStruct(tc.attr) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.value, value) + } + }) + } +} + +type groupMapper func(groups []string, a slog.Attr) slog.Attr + +type mapper func(a slog.Attr) slog.Attr + +func removeMapper(_ slog.Attr) slog.Attr { + return slog.Attr{} +} + +func genReplace(r slog.Attr, groups ...string) groupMapper { + return func(g []string, a slog.Attr) slog.Attr { + if reflect.DeepEqual(groups, g) { + return r + } + return a + } +} + +func genMapper(mapper mapper, groups []string, keys ...string) groupMapper { + return func(g []string, a slog.Attr) slog.Attr { + for _, key := range keys { + if reflect.DeepEqual(groups, g) && a.Key == key { + return mapper(a) + } + } + return a + } +} + +func groups(groups ...string) []string { + return groups +} + +func TestWrapAttrMapper(t *testing.T) { + tests := map[string]struct { + groups []string + attr slog.Attr + mapper groupMapper + expected slog.Attr + }{ + "simple replacement": {nil, slog.Int("a", 1), genReplace(slog.Int("b", 2)), slog.Int("b", 2)}, + "inside group": {groups("g", "h"), slog.Int("a", 1), genReplace(slog.Int("b", 2), "g", "h"), slog.Int("b", 2)}, + "with group": {groups("g"), slog.Group("h", slog.Int("a", 1)), genReplace(slog.Int("b", 2), "g", "h"), slog.Group("h", slog.Int("b", 2))}, + "group replace": {groups("g"), slog.Group("h", slog.Int("a", 1), slog.Int("b", 2)), genMapper(removeMapper, groups("g", "h"), "a"), slog.Group("h", slog.Int("b", 2))}, + "group replace empty": {groups("g"), slog.Group("h", slog.Int("a", 1)), genReplace(slog.Attr{}, "g", "h"), slog.Attr{}}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + m := attr.WrapAttrMapper(tc.mapper) + actual := m(tc.groups, tc.attr) + + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestWrapAttrMapper_nil(t *testing.T) { + assert.Nil(t, attr.WrapAttrMapper(nil)) +} + +const rfc3339Millis = "2006-01-02T15:04:05.000Z07:00" + +func TestWriteTimeRFC3339(t *testing.T) { + for _, tm := range []time.Time{ + time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC), + time.Date(2000, 1, 2, 3, 4, 5, 400, time.Local), + time.Date(2000, 11, 12, 3, 4, 500, 5e7, time.UTC), + } { + got := attr.AppendRFC3339Millis(tm) + want := `"` + tm.Format(rfc3339Millis) + `"` + if got != want { + t.Errorf("got %s, want %s", got, want) + } + } +} + +func BenchmarkWriteTime(b *testing.B) { + tm := time.Date(2022, 3, 4, 5, 6, 7, 823456789, time.Local) + b.ResetTimer() + for i := 0; i < b.N; i++ { + attr.AppendRFC3339Millis(tm) + } +} diff --git a/internal/level/level.go b/internal/level/level.go new file mode 100644 index 0000000..4a9766f --- /dev/null +++ b/internal/level/level.go @@ -0,0 +1,30 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package level + +import ( + "log/slog" + + "cloud.google.com/go/logging" +) + +// LevelToSeverity converts slog.Level logging levels to logging.Severity. +func LevelToSeverity(level slog.Level) logging.Severity { + severity := logging.Severity((int(level) + 8) / 4 * 100) + if slog.LevelInfo < level { + return severity + 100 + } + return severity +} diff --git a/internal/options/option.go b/internal/options/option.go new file mode 100644 index 0000000..4123667 --- /dev/null +++ b/internal/options/option.go @@ -0,0 +1,105 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package options holds the options handling code. + +The Options struct is held in this internal package to button down access. +*/ +package options + +import ( + "context" + "log/slog" + "math" + + "cloud.google.com/go/logging" +) + +var ( + levelUnknown = slog.Level(math.MaxInt) +) + +// Options holds information needed to construct an instance of GcpHandler. +type Options struct { + ExplicitLogLevel slog.Level + EnvVarLogLevel slog.Level + DefaultLogLevel slog.Level + + EntryAugmentors []func(ctx context.Context, e *logging.Entry) + + // AddSource causes the handler to compute the source code position + // of the log statement and add a SourceKey attribute to the output. + AddSource bool + + // Level reports the minimum record level that will be logged. + // The handler discards records with lower levels. + // If Level is nil, the handler assumes LevelInfo. + // The handler calls Level.Level() for each record processed; + // to adjust the minimum level dynamically, use a LevelVar. + Level slog.Leveler + + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. + // The attribute's value has been resolved (see [Value.Resolve]). + // If ReplaceAttr returns a zero Attr, the attribute is discarded. + // + // The built-in attributes with keys "time", "level", "source", and "msg" + // are passed to this function, except that time is omitted + // if zero, and source is omitted if addSource is false. + // + // The first argument is a list of currently open groups that contain the + // Attr. It must not be retained or modified. ReplaceAttr is never called + // for Group attributes, only their contents. For example, the attribute + // list + // + // Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) + // + // results in consecutive calls to ReplaceAttr with the following arguments: + // + // nil, Int("a", 1) + // []string{"g"}, Int("b", 2) + // nil, Int("c", 3) + // + // ReplaceAttr can be used to change the default keys of the built-in + // attributes, convert types (for example, to replace a `time.Time` with the + // integer seconds since the Unix epoch), sanitize personal information, or + // remove attributes from the output. + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +type OptionProcessor func(o *Options) + +func ApplyOptions(opts ...OptionProcessor) *Options { + o := &Options{ + EnvVarLogLevel: levelUnknown, + ExplicitLogLevel: levelUnknown, + DefaultLogLevel: levelUnknown, + } + for _, opt := range opts { + opt(o) + } + + o.Level = o.DefaultLogLevel + if o.EnvVarLogLevel != levelUnknown { + o.Level = o.EnvVarLogLevel + } + if o.ExplicitLogLevel != levelUnknown { + o.Level = o.ExplicitLogLevel + } + if o.Level == levelUnknown { + o.Level = slog.LevelInfo + } + + return o +} diff --git a/labels.go b/labels.go new file mode 100644 index 0000000..eedab6a --- /dev/null +++ b/labels.go @@ -0,0 +1,71 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" +) + +// LabelPair represents a key-value string pair. +type LabelPair struct { + valid bool + key string + val string +} + +// Label returns a new LabelPair from a key and a value. +func Label(key, value string) LabelPair { + return LabelPair{valid: true, key: key, val: value} +} + +type labelsKey struct{} + +type labeler func(ctx context.Context, lbls map[string]string) + +func doNothing(context.Context, map[string]string) {} + +// WithLabels returns a new Context with labels to be used in the GCP log +// entries produced using that context. +func WithLabels(ctx context.Context, labels ...LabelPair) context.Context { + parent := labelsFrom(ctx) + return context.WithValue(ctx, labelsKey{}, + labeler(func(ctx context.Context, lbls map[string]string) { + parent(ctx, lbls) + for _, l := range labels { + if !l.valid { + panic("invalid label passed to WithLabels()") + } + lbls[l.key] = l.val + } + }), + ) +} + +// ExtractLabels extracts labels from the ctx. These labels were associated +// with the context using WithLabels. +func ExtractLabels(ctx context.Context) map[string]string { + labels := make(map[string]string) + lblr := labelsFrom(ctx) + lblr(ctx, labels) + return labels +} + +func labelsFrom(ctx context.Context) labeler { + v, ok := ctx.Value(labelsKey{}).(labeler) + if !ok { + return doNothing + } + return v +} diff --git a/labels_test.go b/labels_test.go new file mode 100644 index 0000000..2e2b22e --- /dev/null +++ b/labels_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "m4o.io/gslog" +) + +var _ = Describe("gslog labels", func() { + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + }) + + When("context is initialized with bad labels", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.LabelPair{}) + }) + + It("should panic when extracting from the context", func() { + Ω(func() { + gslog.ExtractLabels(ctx) + }).Should(PanicWith("invalid label passed to WithLabels()")) + }) + }) + + When("context is initialized with several labels", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.Label("how", "now"), gslog.Label("brown", "cow")) + }) + + It("they can be extracted from the context", func() { + lbls := gslog.ExtractLabels(ctx) + + Ω(lbls).Should(HaveLen(2)) + Ω(lbls).Should(HaveKeyWithValue("how", "now")) + Ω(lbls).Should(HaveKeyWithValue("brown", "cow")) + }) + + Context("and a label overridden", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.Label("brown", "cat")) + }) + + It("the overrides can be extracted from the context", func() { + lbls := gslog.ExtractLabels(ctx) + + Ω(lbls).Should(HaveLen(2)) + Ω(lbls).Should(HaveKeyWithValue("how", "now")) + Ω(lbls).Should(HaveKeyWithValue("brown", "cat")) + }) + }) + }) +}) + +const ( + count = 10 +) + +var ( + labels map[string]string + ctx context.Context +) + +type mockKey struct{} + +func init() { + labels = make(map[string]string, count) + for i := range count { + key := fmt.Sprintf("key_%06d", i) + value := fmt.Sprintf("val_%06d", i) + + labels[key] = value + } + + ctx = context.Background() + + for i := range count { + k := fmt.Sprintf("key_%06d", i) + v := fmt.Sprintf("overridden_%06d", i) + + ctx = gslog.WithLabels(ctx, gslog.Label(k, v)) + ctx = context.WithValue(ctx, mockKey{}, v) + } + + for i := range count { + k := fmt.Sprintf("key_%06d", i) + v := fmt.Sprintf("val_%06d", i) + + ctx = gslog.WithLabels(ctx, gslog.Label(k, v)) + ctx = context.WithValue(ctx, mockKey{}, v) + } +} + +func BenchmarkExtractLabels(b *testing.B) { + gslog.ExtractLabels(ctx) +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..ef436fc --- /dev/null +++ b/logger.go @@ -0,0 +1,60 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" + + "cloud.google.com/go/logging" +) + +// Logger is wraps the set of methods that are used when interacting with a +// logging.Logger. This interface facilitates stubbing out calls to the Logger +// for the purposes of testing and benchmarking. +type Logger interface { + Log + LogSync +} + +type Log interface { + Log(e logging.Entry) +} + +type LogSync interface { + LogSync(ctx context.Context, e logging.Entry) error +} + +type LoggerFn func(e logging.Entry) + +func (fn LoggerFn) Log(e logging.Entry) { + fn(e) +} + +func (fn LoggerFn) LogSync(_ context.Context, e logging.Entry) error { + fn(e) + return nil +} + +// discard can be used as a do-nothing Logger that can be used for testing and +// to stub out Google Cloud Logging when benchmarking. +type discard struct{} + +func (d discard) Log(_ logging.Entry) {} + +func (d discard) LogSync(_ context.Context, _ logging.Entry) error { + return nil +} + +var Discard Logger = discard{} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..75d00ea --- /dev/null +++ b/logger_test.go @@ -0,0 +1,36 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "testing" + + "cloud.google.com/go/logging" + "github.com/stretchr/testify/assert" + + "m4o.io/gslog" +) + +func TestDiscard_Log(t *testing.T) { + l := gslog.Discard + l.Log(logging.Entry{}) +} + +func TestDiscard_LogSync(t *testing.T) { + l := gslog.Discard + err := l.LogSync(context.Background(), logging.Entry{}) + assert.NoError(t, err) +} diff --git a/option.go b/option.go new file mode 100644 index 0000000..9abd83c --- /dev/null +++ b/option.go @@ -0,0 +1,95 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "log/slog" + "os" + "strconv" + + "m4o.io/gslog/internal/options" +) + +// Options holds information needed to construct an instance of GcpHandler. +type Options struct { + options.Options +} + +// WithLogLevel returns an option that specifies the log level for logging. +// Explicitly setting the log level here takes precedence over the other +// options. +func WithLogLevel(logLevel slog.Level) options.OptionProcessor { + return func(o *options.Options) { + o.ExplicitLogLevel = logLevel + } +} + +// WithLogLevelFromEnvVar returns an option that specifies the log level +// for logging comes from tne environmental variable specified by the key. +func WithLogLevelFromEnvVar(key string) options.OptionProcessor { + if key == "" { + panic("Env var key is empty") + } + + return func(o *options.Options) { + s, ok := os.LookupEnv(key) + if !ok { + return + } + i, err := strconv.Atoi(s) + if err == nil { + o.EnvVarLogLevel = slog.Level(i) + return + } + + switch s { + case "DEBUG": + o.EnvVarLogLevel = slog.LevelDebug + case "INFO": + o.EnvVarLogLevel = slog.LevelInfo + case "WARN": + o.EnvVarLogLevel = slog.LevelWarn + case "ERROR": + o.EnvVarLogLevel = slog.LevelError + default: + o.EnvVarLogLevel = slog.LevelInfo + } + } +} + +// WithDefaultLogLevel returns an option that specifies the default log +// level for logging. +func WithDefaultLogLevel(defaultLogLevel slog.Level) options.OptionProcessor { + return func(o *options.Options) { + o.DefaultLogLevel = defaultLogLevel + } +} + +// WithSourceAdded returns an option that causes the handler to compute the +// source code position of the log statement and add a slog.SourceKey attribute +// to the output. +func WithSourceAdded() options.OptionProcessor { + return func(o *options.Options) { + o.AddSource = true + } +} + +// WithReplaceAttr returns an option that specifies an attribute mapper used to +// rewrite each non-group attribute before it is logged. +func WithReplaceAttr(replaceAttr AttrMapper) options.OptionProcessor { + return func(o *options.Options) { + o.ReplaceAttr = replaceAttr + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..4962964 --- /dev/null +++ b/option_test.go @@ -0,0 +1,109 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "log/slog" + "math" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "m4o.io/gslog" + "m4o.io/gslog/internal/options" +) + +const ( + naString = "" + envVarLogLevelKey = "TEST_ENV_VAR" + levelUnknown = slog.Level(math.MaxInt) +) + +func TestLogLevel(t *testing.T) { + tests := map[string]struct { + explicitLogLevel slog.Level + defaultLogLevel slog.Level + envVar bool + envVarKey string + envVarValue string + expected slog.Level + }{ + "do nothing": {levelUnknown, levelUnknown, false, naString, naString, slog.LevelInfo}, + "default": {levelUnknown, slog.LevelInfo, false, naString, naString, slog.LevelInfo}, + "default missing env var": {levelUnknown, slog.LevelInfo, true, naString, naString, slog.LevelInfo}, + "explicit": {slog.LevelInfo, levelUnknown, false, naString, naString, slog.LevelInfo}, + "explicit overrides env var": {slog.LevelInfo, levelUnknown, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "explicit overrides default": {slog.LevelInfo, slog.LevelDebug, false, naString, naString, slog.LevelInfo}, + "explicit overrides all": {slog.LevelInfo, slog.LevelDebug, true, envVarLogLevelKey, "ERROR", slog.LevelInfo}, + "env var garbage": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "OUCH", slog.LevelInfo}, + "env var DEBUG": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "DEBUG", slog.LevelDebug}, + "env var INFO": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "env var WARN": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "WARN", slog.LevelWarn}, + "env var ERROR": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "ERROR", slog.LevelError}, + "env var missing": {levelUnknown, levelUnknown, true, naString, naString, slog.LevelInfo}, + "env var overrides default": {levelUnknown, slog.LevelDebug, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "env var high custom level": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "32", slog.Level(32)}, + "env var low custom level": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "-8", slog.Level(-8)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var opts []options.OptionProcessor + if tc.explicitLogLevel != levelUnknown { + opts = append(opts, gslog.WithLogLevel(tc.explicitLogLevel)) + } + if tc.defaultLogLevel != levelUnknown { + opts = append(opts, gslog.WithDefaultLogLevel(tc.defaultLogLevel)) + } + if tc.envVar { + if tc.envVarKey != "" { + assert.NoError(t, os.Setenv(tc.envVarKey, tc.envVarValue)) + defer func() { + assert.NoError(t, os.Unsetenv(envVarLogLevelKey)) + }() + } + opts = append(opts, gslog.WithLogLevelFromEnvVar(envVarLogLevelKey)) + } + + o := options.ApplyOptions(opts...) + assert.Equal(t, tc.expected, o.Level) + }) + } +} + +func TestWithLogLevelFromEnvVar(t *testing.T) { + defer func() { + if x := recover(); x == nil { + t.Error("expected panic") + } + }() + gslog.WithLogLevelFromEnvVar("") +} + +func TestWithSourceAdded(t *testing.T) { + o := options.ApplyOptions(gslog.WithSourceAdded(), gslog.WithDefaultLogLevel(slog.LevelInfo)) + assert.True(t, o.AddSource) +} + +func TestWithReplaceAttr(t *testing.T) { + s := slog.String("foo", "bar") + var ra gslog.AttrMapper = func(groups []string, a slog.Attr) slog.Attr { + return s + } + + o := options.ApplyOptions(gslog.WithReplaceAttr(ra), gslog.WithDefaultLogLevel(slog.LevelInfo)) + assert.Equal(t, s, o.ReplaceAttr(nil, slog.String("unused", "string"))) +} diff --git a/otel/trace.go b/otel/trace.go new file mode 100644 index 0000000..dd0987a --- /dev/null +++ b/otel/trace.go @@ -0,0 +1,53 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/* +Package otel contains options for including OpenTelemetry tracing in logging +records. + +Placing the options in a separate package minimizes the dependencies pulled in +by those who do not need OpenTelemetry tracing. +*/ +package otel + +import ( + "context" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/trace" + + "m4o.io/gslog/internal/options" +) + +// WithOtelTracing returns a gslog.Option that directs that the slog.Handler +// to include OpenTelemetry tracing. +func WithOtelTracing() options.OptionProcessor { + return func(options *options.Options) { + options.EntryAugmentors = append(options.EntryAugmentors, addTrace) + } +} + +func addTrace(ctx context.Context, e *logging.Entry) { + span := trace.SpanContextFromContext(ctx) + + if span.HasTraceID() { + e.Trace = span.TraceID().String() + } + if span.HasSpanID() { + e.SpanID = span.SpanID().String() + } + if span.IsSampled() { + e.TraceSampled = true + } +} diff --git a/otel/trace_test.go b/otel/trace_test.go new file mode 100644 index 0000000..8e8e2bf --- /dev/null +++ b/otel/trace_test.go @@ -0,0 +1,69 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package otel_test + +import ( + "context" + "log/slog" + "testing" + + "cloud.google.com/go/logging" + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/trace" + + "m4o.io/gslog" + "m4o.io/gslog/otel" +) + +type Got struct { + LogEntry logging.Entry + SyncLogEntry logging.Entry +} + +func (g *Got) Log(e logging.Entry) { + g.LogEntry = e +} + +func (g *Got) LogSync(_ context.Context, e logging.Entry) error { + g.SyncLogEntry = e + return nil +} + +func TestWithOtelTracing(t *testing.T) { + traceID, _ := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + + sCtx := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsSampled, + Remote: true, + }) + + ctx := context.Background() + ctx = trace.ContextWithRemoteSpanContext(ctx, sCtx) + + got := &Got{} + h := gslog.NewGcpHandler(got, otel.WithOtelTracing()) + l := slog.New(h) + + l.Log(ctx, slog.LevelInfo, "how now brown cow") + + e := got.LogEntry + + assert.Equal(t, traceID.String(), e.Trace) + assert.Equal(t, spanID.String(), e.SpanID) + assert.True(t, e.TraceSampled) +} diff --git a/podinfo.go b/podinfo.go new file mode 100644 index 0000000..11c1abe --- /dev/null +++ b/podinfo.go @@ -0,0 +1,74 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" + "log/slog" + "os" + "path/filepath" + + "cloud.google.com/go/logging" + "github.com/magiconair/properties" + + "m4o.io/gslog/internal/options" +) + +const ( + // K8sPodPrefix is the prefix for labels obtained from the Kubernetes + // Downward API podinfo labels file. + K8sPodPrefix = "k8s-pod/" +) + +// WithPodinfoLabels returns a Option that directs that the slog.Handler to +// include labels from the Kubernetes Downward API podinfo labels file. The +// labels file is expected to be found in the directory specified by root and +// MUST be named "labels", per the Kubernetes Downward API for Pods. +// +// The labels are prefixed with "k8s-pod/" to adhere to the Google Cloud +// Logging conventions for Kubernetes Pod labels. +func WithPodinfoLabels(root string) options.OptionProcessor { + return func(options *options.Options) { + options.EntryAugmentors = append(options.EntryAugmentors, podinfoAugmentor(root)) + } +} + +func podinfoAugmentor(root string) func(ctx context.Context, e *logging.Entry) { + return func(ctx context.Context, e *logging.Entry) { + if e.Labels == nil { + e.Labels = make(map[string]string) + } + + path := filepath.Join(root, "labels") + p, err := properties.LoadFile(path, properties.UTF8) + if err != nil { + if os.IsNotExist(err) { + slog.Warn("Podinfo file does not exist", "path", path) + } else { + slog.Warn("Unable to load podinfo labels", "path", path, "error", err) + } + return + } + + for k, v := range p.Map() { + if v[0] == '"' { + v = v[1 : len(v)-1] + } + + key := K8sPodPrefix + k + e.Labels[key] = v + } + } +} diff --git a/podinfo_test.go b/podinfo_test.go new file mode 100644 index 0000000..5c9b0f0 --- /dev/null +++ b/podinfo_test.go @@ -0,0 +1,96 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + + "cloud.google.com/go/logging" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" + + "m4o.io/gslog" + "m4o.io/gslog/internal/options" +) + +var _ = Describe("Kubernetes podinfo labels", func() { + var ctx context.Context + var o *options.Options + var root string + + BeforeEach(func() { + ctx = context.Background() + o = &options.Options{} + Ω(1).Should(Equal(1)) + }) + + JustBeforeEach(func() { + gslog.WithPodinfoLabels(root)(o) + }) + + When("the podinfo labels file exists", func() { + BeforeEach(func() { + root = "testdata/etc/podinfo" + }) + + It("the labels are loaded and properly prefixed", + func() { + e := &logging.Entry{} + for _, a := range o.EntryAugmentors { + a(ctx, e) + } + + Ω(e.Labels).Should(MatchAllKeys(Keys{ + gslog.K8sPodPrefix + "app": Equal("hello-world"), + gslog.K8sPodPrefix + "environment": Equal("stg"), + gslog.K8sPodPrefix + "tier": Equal("backend"), + gslog.K8sPodPrefix + "track": Equal("stable"), + })) + }) + }) + + When("the podinfo labels file does not exists", func() { + BeforeEach(func() { + root = "ouch" + }) + + It("no error occurs and no labels are loaded", + func() { + e := &logging.Entry{} + for _, a := range o.EntryAugmentors { + a(ctx, e) + } + + Ω(e.Labels).Should(BeEmpty()) + }) + }) + + When("the podinfo labels file exists but contents are bad", func() { + BeforeEach(func() { + root = "testdata/ouch/podinfo" + }) + + It("no error occurs and no labels are loaded", + func() { + e := &logging.Entry{} + for _, a := range o.EntryAugmentors { + a(ctx, e) + } + + Ω(e.Labels).Should(BeEmpty()) + }) + }) +}) diff --git a/severity.go b/severity.go new file mode 100644 index 0000000..54c97cd --- /dev/null +++ b/severity.go @@ -0,0 +1,32 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "log/slog" +) + +const ( + // LevelNotice means normal but significant events, such as start up, + // shut down, or configuration. + LevelNotice = slog.Level(2) + // LevelCritical means events that cause more severe problems or brief + // outages. + LevelCritical = slog.Level(12) + // LevelAlert means a person must take an action immediately. + LevelAlert = slog.Level(16) + // LevelEmergency means one or more systems are unusable. + LevelEmergency = slog.Level(20) +) diff --git a/severity_test.go b/severity_test.go new file mode 100644 index 0000000..3d9727c --- /dev/null +++ b/severity_test.go @@ -0,0 +1,49 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "log/slog" + "testing" + + "cloud.google.com/go/logging" + "github.com/stretchr/testify/assert" + + "m4o.io/gslog" + "m4o.io/gslog/internal/level" +) + +func TestLevelToSeverity(t *testing.T) { + tests := map[string]struct { + level slog.Level + expected logging.Severity + }{ + "trace": {slog.Level(-8), logging.Severity(0)}, + "debug": {slog.LevelDebug, logging.Debug}, + "info": {slog.LevelInfo, logging.Info}, + "notice": {gslog.LevelNotice, logging.Notice}, + "warn": {slog.LevelWarn, logging.Warning}, + "error": {slog.LevelError, logging.Error}, + "critical": {gslog.LevelCritical, logging.Critical}, + "alert": {gslog.LevelAlert, logging.Alert}, + "emergency": {gslog.LevelEmergency, logging.Emergency}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + assert.Equal(t, tc.expected, level.LevelToSeverity(tc.level)) + }) + } +} diff --git a/testdata/etc/podinfo/labels b/testdata/etc/podinfo/labels new file mode 100644 index 0000000..1da6341 --- /dev/null +++ b/testdata/etc/podinfo/labels @@ -0,0 +1,4 @@ +app="hello-world" +environment="stg" +tier="backend" +track="stable" diff --git a/testdata/ouch/podinfo/labels b/testdata/ouch/podinfo/labels new file mode 100644 index 0000000..44e48db --- /dev/null +++ b/testdata/ouch/podinfo/labels @@ -0,0 +1,2 @@ +a="${b}" +b="${a}"