-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"] |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
name: Workflow for CI | ||
on: [ push, pull_request ] | ||
jobs: | ||
run: | ||
runs-on: ubuntu-latest | ||
strategy: | ||
matrix: | ||
go-version: [ '1.19', '1.20', '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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
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' | ||
|
||
- type: tests | ||
goversion: '1.20' | ||
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/... |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,7 @@ | ||
# gslog | ||
An slog Handler for Google Cloud Logging | ||
|
||
[](http://godoc.org/github.com/maguro/gslog) | ||
[](https://goreportcard.com/report/github.com/maguro/gslog) | ||
[](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). |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
Check failure on line 18 in attr.go
|
||
) | ||
|
||
// 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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
module m4o.io/gslog | ||
|
||
go 1.19 | ||
|
||
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 | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
Check failure on line 23 in handler.go
|
||
|
||
"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) | ||
} | ||
} |
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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("<secret>")), 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("<secret>") | ||
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": "<secret>", | ||
"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: "<secret>"}} | ||
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("<secret>"), 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) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 { | ||
Check failure on line 88 in labels_test.go
|
||
key := fmt.Sprintf("key_%06d", i) | ||
value := fmt.Sprintf("val_%06d", i) | ||
|
||
labels[key] = value | ||
} | ||
|
||
ctx = context.Background() | ||
|
||
for i := range count { | ||
Check failure on line 97 in labels_test.go
|
||
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 { | ||
Check failure on line 105 in labels_test.go
|
||
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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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{} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"))) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()) | ||
}) | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
app="hello-world" | ||
environment="stg" | ||
tier="backend" | ||
track="stable" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
a="${b}" | ||
b="${a}" |