Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Initial commit
Browse files Browse the repository at this point in the history
maguro committed Apr 10, 2024
1 parent d06ea2e commit eb9662b
Showing 32 changed files with 3,539 additions and 1 deletion.
25 changes: 25 additions & 0 deletions .github/codecov.yml
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"
18 changes: 18 additions & 0 deletions .github/dependabot.yml
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"]
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
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

37 changes: 37 additions & 0 deletions .github/workflows/codeql-analysis.yml
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
23 changes: 23 additions & 0 deletions .github/workflows/coverage.yml
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
80 changes: 80 additions & 0 deletions .github/workflows/testing.yml
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/...
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).
44 changes: 44 additions & 0 deletions attr.go
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

GitHub Actions / tests (tests, 1.20)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/log/slog)

Check failure on line 18 in attr.go

GitHub Actions / tests (tests, 1.20)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/log/slog)

Check failure on line 18 in attr.go

GitHub Actions / run (1.19)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.19.13/x64/src/log/slog)

Check failure on line 18 in attr.go

GitHub Actions / run (1.20)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/log/slog)

Check failure on line 18 in attr.go

GitHub Actions / run (1.20)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/log/slog)

Check failure on line 18 in attr.go

GitHub Actions / run (1.19)

package log/slog is not in GOROOT (/opt/hostedtoolcache/go/1.19.13/x64/src/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
16 changes: 16 additions & 0 deletions doc.go
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
47 changes: 47 additions & 0 deletions go.mod
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
)
178 changes: 178 additions & 0 deletions go.sum

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions gslog_suite_test.go
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)
}
244 changes: 244 additions & 0 deletions handler.go
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

GitHub Actions / tests (tests, 1.20)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/slices)

Check failure on line 23 in handler.go

GitHub Actions / tests (tests, 1.20)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/slices)

Check failure on line 23 in handler.go

GitHub Actions / run (1.19)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.19.13/x64/src/slices)

Check failure on line 23 in handler.go

GitHub Actions / run (1.20)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/slices)

Check failure on line 23 in handler.go

GitHub Actions / run (1.20)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.20.14/x64/src/slices)

Check failure on line 23 in handler.go

GitHub Actions / run (1.19)

package slices is not in GOROOT (/opt/hostedtoolcache/go/1.19.13/x64/src/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)
}
}
1,169 changes: 1,169 additions & 0 deletions handler_test.go

Large diffs are not rendered by default.

239 changes: 239 additions & 0 deletions internal/attr/attr.go
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)
}
318 changes: 318 additions & 0 deletions internal/attr/attr_test.go
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)
}
}
30 changes: 30 additions & 0 deletions internal/level/level.go
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
}
105 changes: 105 additions & 0 deletions internal/options/option.go
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
}
71 changes: 71 additions & 0 deletions labels.go
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
}
116 changes: 116 additions & 0 deletions labels_test.go
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

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / run (1.22.x)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 88 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)
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

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / run (1.22.x)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 97 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)
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

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.21)

cannot range over count (untyped int constant 10)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, arm64)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / upload

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, -race)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / run (1.22.x)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod) (compile)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)

Check failure on line 105 in labels_test.go

GitHub Actions / tests (tests, 1.22, 386)

cannot range over count (untyped int constant 10): requires go1.22 or later (-lang was set to go1.19; check go.mod)
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)
}
60 changes: 60 additions & 0 deletions logger.go
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{}
36 changes: 36 additions & 0 deletions logger_test.go
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)
}
95 changes: 95 additions & 0 deletions option.go
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
}
}
109 changes: 109 additions & 0 deletions option_test.go
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")))
}
53 changes: 53 additions & 0 deletions otel/trace.go
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
}
}
69 changes: 69 additions & 0 deletions otel/trace_test.go
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)
}
74 changes: 74 additions & 0 deletions podinfo.go
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
}
}
}
96 changes: 96 additions & 0 deletions podinfo_test.go
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())
})
})
})
32 changes: 32 additions & 0 deletions severity.go
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)
)
49 changes: 49 additions & 0 deletions severity_test.go
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))
})
}
}
4 changes: 4 additions & 0 deletions testdata/etc/podinfo/labels
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
app="hello-world"
environment="stg"
tier="backend"
track="stable"
2 changes: 2 additions & 0 deletions testdata/ouch/podinfo/labels
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
a="${b}"
b="${a}"

0 comments on commit eb9662b

Please sign in to comment.