From 113ee250a93df280a08cd4ea3ab4954f1e752691 Mon Sep 17 00:00:00 2001 From: "Alan D. Cabrera" Date: Sun, 24 Mar 2024 12:28:46 -0700 Subject: [PATCH] Initial commit --- .github/codecov.yml | 25 + .github/dependabot.yml | 18 + .github/workflows/ci.yml | 62 ++ README.md | 81 +- attr.go | 44 ++ doc.go | 16 + example_test.go | 332 ++++++++ go.mod | 53 ++ go.sum | 210 +++++ gslog_suite_test.go | 29 + handler.go | 255 ++++++ handler_test.go | 1172 ++++++++++++++++++++++++++++ internal/attr/attr.go | 238 ++++++ internal/attr/attr_test.go | 318 ++++++++ internal/level/level.go | 30 + internal/level/level_suite_test.go | 29 + internal/level/level_test.go | 41 + internal/options/option.go | 107 +++ k8s/podinfo.go | 81 ++ k8s/podinfo_suite_test.go | 29 + k8s/podinfo_test.go | 96 +++ k8s/testdata/etc/podinfo/labels | 4 + k8s/testdata/ouch/podinfo/labels | 2 + labels.go | 71 ++ labels_test.go | 116 +++ logger.go | 86 ++ logger_test.go | 62 ++ option.go | 101 +++ option_test.go | 109 +++ otel/baggage.go | 161 ++++ otel/baggage_test.go | 143 ++++ otel/doc.go | 22 + otel/trace.go | 47 ++ otel/trace_test.go | 73 ++ severity.go | 32 + 35 files changed, 4294 insertions(+), 1 deletion(-) create mode 100644 .github/codecov.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 attr.go create mode 100644 doc.go create mode 100644 example_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 gslog_suite_test.go create mode 100644 handler.go create mode 100644 handler_test.go create mode 100644 internal/attr/attr.go create mode 100644 internal/attr/attr_test.go create mode 100644 internal/level/level.go create mode 100644 internal/level/level_suite_test.go create mode 100644 internal/level/level_test.go create mode 100644 internal/options/option.go create mode 100644 k8s/podinfo.go create mode 100644 k8s/podinfo_suite_test.go create mode 100644 k8s/podinfo_test.go create mode 100644 k8s/testdata/etc/podinfo/labels create mode 100644 k8s/testdata/ouch/podinfo/labels create mode 100644 labels.go create mode 100644 labels_test.go create mode 100644 logger.go create mode 100644 logger_test.go create mode 100644 option.go create mode 100644 option_test.go create mode 100644 otel/baggage.go create mode 100644 otel/baggage_test.go create mode 100644 otel/doc.go create mode 100644 otel/trace.go create mode 100644 otel/trace_test.go create mode 100644 severity.go diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 0000000..ea9e773 --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,25 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +ignore: + # All 'pb.go's. + - "**/*.pb.go" + # Tests and test related files. + - "**/test" + - "**/testdata" + - "**/testutils" + - "benchmark" + - "interop" + # Other submodules. + - "cmd" + - "examples" + - "gcp" + - "security" + - "stats/opencensus" +comment: + layout: "header, diff, files" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a078332 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + github-actions: + patterns: + - "*" + ignore: + - dependency-name: "*" + update-types: ["version-update:semver-major"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c30cabc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: Workflow for CI +on: [ push, pull_request ] +jobs: + run: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [ '1.21', '1.22' ] + 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: Upload coverage to Codecov + uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: maguro/gslog + flags: smart-tests + verbose: true + diff --git a/README.md b/README.md index 64f752c..418b0dc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,81 @@ # gslog -An slog Handler for Google Cloud Logging + +![Go Version](https://img.shields.io/badge/Go-%3E%3D%201.21-%23007d9c) +[![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) +[![License](https://img.shields.io/github/license/maguro/gslog)](./LICENSE) + +A Google Cloud Logging [Handler](https://pkg.go.dev/log/slog#Handler) implementation +for [slog](https://go.dev/blog/slog). + +--- + +Critical level log records will be sent synchronously. + +## Install + +```sh +go get m4o.io/gslog +``` + +**Compatibility**: go >= 1.21 + +## Example Usage + +First create a [Google Cloud Logging](https://pkg.go.dev/cloud.google.com/go/logging) +`logging.Client` to use throughout your application: + +```go +ctx := context.Background() +client, err := logging.NewClient(ctx, "my-project") +if err != nil { + // TODO: Handle error. +} +``` + +Usually, you'll want to add log entries to a buffer to be periodically flushed +(automatically and asynchronously) to the Cloud Logging service. Use the +logger when creating the new `gslog.GcpHandler` which is passed to `slog.New()` +to obtain a `slog`-based logger. + +```go +loggger := client.Logger("my-log") + +h := gslog.NewGcpHandler(loggger) +l := slog.New(h) + +l.Info("How now brown cow?") +``` + +Writing critical, or higher, log level entries will be sent synchronously. + +```go +l.Log(context.Background(), gslog.LevelCritical, "Danger, Will Robinson!") +``` + +Close your client before your program exits, to flush any buffered log entries. + +```go +err = client.Close() +if err != nil { + // TODO: Handle error. +} +``` + +## Logger Configuration Options + +Creating a Google Cloud Logging [Handler](https://pkg.go.dev/log/slog#Handler) using `gslog.NewGcpHandler(logger, ...options)` accepts the +following options: + +| Configuration option | Arguments | Description | +|----------------------------------------|:------------------:|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `gslog.WithLogLeveler(leveler)` | `slog.Leveler` | Specifies the `slog.Leveler` for logging. Explicitly setting the log level here takes precedence over the other options. | +| `gslog.WithLogLevelFromEnvVar(envVar)` | `string` | Specifies the log level for logging comes from tne environmental variable specified by the key. | +| `gslog.WithDefaultLogLeveler()` | `slog.Leveler` | Specifies the default `slog.Leveler` for logging. | +| `gslog.WithSourceAdded()` | | Causes the handler to compute the source code position of the log statement and add a `slog.SourceKey` attribute to the output. | +| `gslog.WithLabels()` | | Adds any labels found in the context to the `logging.Entry`'s `Labels` field. | +| `gslog.WithReplaceAttr(mapper)` | `gslog.AttrMapper` | Specifies an attribute mapper used to rewrite each non-group attribute before it is logged. | +| `otel.WithOtelBaggage()` | | Directs that the `slog.Handler` to include [OpenTelemetry baggage](https://opentelemetry.io/docs/concepts/signals/baggage/). The `baggage.Baggage` is obtained from the context, if available, and added as attributes. | +| `otel.WithOtelTracing()` | | Directs that the `slog.Handler` to include [OpenTelemetry tracing](https://opentelemetry.io/docs/concepts/signals/traces/). Tracing information is obtained from the `trace.SpanContext` stored in the context, if provided. | +| `k8s.WithPodinfoLabels(root)` | `string` | Directs that the `slog.Handler` to include labels from the [Kubernetes Downward API](https://kubernetes.io/docs/concepts/workloads/pods/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. | diff --git a/attr.go b/attr.go new file mode 100644 index 0000000..aac0651 --- /dev/null +++ b/attr.go @@ -0,0 +1,44 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "log/slog" +) + +// AttrMapper is called to rewrite each non-group attribute before it is logged. +// The attribute's value has been resolved (see [Value.Resolve]). +// If replaceAttr returns a zero Attr, the attribute is discarded. +// +// The built-in attribute with key "message" is passed to this function. +// +// The first argument is a list of currently open groups that contain the +// Attr. It must not be retained or modified. replaceAttr is never called +// for Group attributes, only their contents. For example, the attribute +// list +// +// Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) +// +// results in consecutive calls to replaceAttr with the following arguments: +// +// nil, Int("a", 1) +// []string{"g"}, Int("b", 2) +// nil, Int("c", 3) +// +// AttrMapper can be used to change the default keys of the built-in +// attributes, convert types (for example, to replace a `time.Time` with the +// integer seconds since the Unix epoch), sanitize personal information, or +// remove attributes from the output. +type AttrMapper func(groups []string, a slog.Attr) slog.Attr diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..b1ba113 --- /dev/null +++ b/doc.go @@ -0,0 +1,16 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gslog contains a GCP logging implementation of slog.Handler. +package gslog diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..1cd0b75 --- /dev/null +++ b/example_test.go @@ -0,0 +1,332 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "sort" + "strconv" + "strings" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/baggage" + "go.opentelemetry.io/otel/trace" + "google.golang.org/protobuf/encoding/protojson" + spb "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog" + "m4o.io/gslog/k8s" + "m4o.io/gslog/otel" +) + +// A gslog.GcpHandler is created with a GCP logging.Logger. The handler will +// map slog.Record records to logging.Entry entries, subsequently passing the +// resulting entries to its configured logging.Logger instance's Log() method. +func ExampleNewGcpHandler() { + ctx := context.Background() + client, err := logging.NewClient(ctx, "my-project") + if err != nil { + // TODO: Handle error. + } + + lg := client.Logger("my-log") + + lg.Flush() + + h := gslog.NewGcpHandler(lg) + l := slog.New(h) + + l.Info("How now brown cow?") +} + +var ( + pw = Password("pass-12334") + pwObfuscated = slog.StringValue("") + u = &User{ + ID: "user-12234", + FirstName: "Jan", + LastName: "Doe", + Email: "jan@example.com", + Password: pw, + Age: 32, + Height: 5.91, + Engineer: true, + } +) + +type Manager struct { +} + +// Password is a specialised type whose fmt.Stringer, json.Marshaler, and +// slog.LogValuer implementations return an obfuscated value. +type Password string + +func (p Password) String() string { + return "" +} + +func (p Password) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote("")), nil +} + +func (p Password) LogValue() slog.Value { + return pwObfuscated +} + +type User struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password Password `json:"password"` + Age uint8 `json:"age"` + Height float32 `json:"height"` + Engineer bool `json:"engineer"` + Manager *Manager `json:"manager"` +} + +// PrintJsonPayload is a gslog.Logger stub that prints the logging.Entry +// Payload field as a JSON string. +func PrintJsonPayload(e logging.Entry) { + b, _ := protojson.Marshal(e.Payload.(*spb.Struct)) + // another JSON round-trip because protojson randomizes output + var j map[string]interface{} + _ = json.Unmarshal(b, &j) + b, _ = json.Marshal(j) + fmt.Println(string(b)) +} + +// The gslog.GcpHandler maps the slog.Record and the handler's nested group +// attributes into a JSON object, with the logged message keyed at the root +// with the key "message". +func ExampleGcpHandler_Handle_payloadMapping() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintJsonPayload)) + l := slog.New(h) + l = l.WithGroup("pub") + l = l.With(slog.Any("user", u)) + + l.Info("How now brown cow?") + + // Output: {"message":"How now brown cow?","pub":{"user":{"age":32,"email":"jan@example.com","engineer":true,"first_name":"Jan","height":5.91,"id":"user-12234","last_name":"Doe","manager":null,"password":"\u003csecret\u003e"}}} +} + +// PrintLabels is a gslog.Logger stub that prints the logging.Entry's +// Labels field. +func PrintLabels(e logging.Entry) { + keys := make([]string, 0) + for k := range e.Labels { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for _, k := range keys { + if sb.Len() > 0 { + sb.WriteString(", ") + } + sb.WriteString(k + "=" + e.Labels[k]) + } + + fmt.Println(sb.String()) +} + +// The gslog.GcpHandler will add any labels found in the context to the +// logging.Entry's Labels field. +func ExampleGcpHandler_Handle_withLabels() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintLabels)) + l := slog.New(h) + + ctx := context.Background() + ctx = gslog.WithLabels(ctx, gslog.Label("a", "one"), gslog.Label("b", "two")) + + l.Log(ctx, slog.LevelInfo, "How now brown cow?") + + // Output: a=one, b=two +} + +// When configured via k8s.WithPodinfoLabels(), gslog.GcpHandler will include +// labels from the configured Kubernetes Downward API podinfo labels file to +// the logging.Entry's Labels field. +// +// The labels are prefixed with "k8s-pod/" to adhere to the Google Cloud +// Logging conventions for Kubernetes Pod labels. +func ExampleNewGcpHandler_withK8sPodinfo() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintLabels), k8s.WithPodinfoLabels("k8s/testdata/etc/podinfo")) + l := slog.New(h) + + ctx := context.Background() + ctx = gslog.WithLabels(ctx, gslog.Label("a", "one"), gslog.Label("b", "two")) + + l.Log(ctx, gslog.LevelCritical, "Danger, Will Robinson!") + + // Output: a=one, b=two, k8s-pod/app=hello-world, k8s-pod/environment=stg, k8s-pod/tier=backend, k8s-pod/track=stable +} + +// When configured via otel.WithOtelBaggage(), gslog.GcpHandler will include +// any baggage.Baggage attached to the context as attributes. +// +// The baggage keys are prefixed with "otel-baggage/" to mitigate collision +// with other log attributes and have precedence over any collisions with +// preexisting attributes. +func ExampleNewGcpHandler_withOpentelemetryBaggage() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintJsonPayload), otel.WithOtelBaggage()) + l := slog.New(h) + + ctx := context.Background() + ctx = baggage.ContextWithBaggage(ctx, otel.MustParse("a=one,b=two;p1;p2=val2")) + + l.Log(ctx, slog.LevelInfo, "How now brown cow?") + + // Output: {"message":"How now brown cow?","otel-baggage/a":"one","otel-baggage/b":{"properties":{"p1":null,"p2":"val2"},"value":"two"}} +} + +// PrintTracing is a gslog.Logger stub that prints the logging.Entry's +// tracing fields. +func PrintTracing(e logging.Entry) { + var sb strings.Builder + + sb.WriteString("traceparent: 00-") + sb.WriteString(e.Trace) + sb.WriteString("-") + sb.WriteString(e.SpanID) + sb.WriteString("-") + if e.TraceSampled { + sb.WriteString("01") + } else { + sb.WriteString("00") + } + + fmt.Println(sb.String()) +} + +// When configured via otel.WithOtelTracing(), gslog.GcpHandler will include +// any OpenTelemetry trace.SpanContext information associated with the context +// in the logging.Entry's tracing fields. +func ExampleNewGcpHandler_withOpentelemetryTrace() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintTracing), otel.WithOtelTracing()) + l := slog.New(h) + + traceId, _ := trace.TraceIDFromHex("52fc1643a9381fc674742bb0067101e7") + spanId, _ := trace.SpanIDFromHex("d3e9e8c51cb190df") + + ctx := context.Background() + ctx = trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceId, + SpanID: spanId, + TraceFlags: trace.FlagsSampled, + })) + + l.Log(ctx, slog.LevelInfo, "How now brown cow?") + + // Output: traceparent: 00-52fc1643a9381fc674742bb0067101e7-d3e9e8c51cb190df-01 +} + +// PrintSourceLocation is a gslog.Logger stub that prints the logging.Entry's +// SourceLocation field. +func PrintSourceLocation(e logging.Entry) { + sl := e.SourceLocation + sl.File = sl.File[len(sl.File)-len("gslog/example_test.go"):] + + b, _ := protojson.Marshal(sl) + // another JSON round-trip because protojson randomizes output + var j map[string]interface{} + _ = json.Unmarshal(b, &j) + b, _ = json.Marshal(j) + fmt.Println(string(b)) +} + +// When configured via gslog.WithSourceAdded(), gslog.GcpHandler will include +// the computationally expensive SourceLocation field in the logging.Entry. +func ExampleNewGcpHandler_withSourceAdded() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintSourceLocation), gslog.WithSourceAdded()) + l := slog.New(h) + + l.Log(ctx, slog.LevelInfo, "How now brown cow?") + + // Output: {"file":"gslog/example_test.go","function":"m4o.io/gslog_test.ExampleNewGcpHandler_withSourceAdded","line":"258"} +} + +// RemovePassword is a gslog.AttrMapper that elides password attributes. +func RemovePassword(_ []string, a slog.Attr) slog.Attr { + if a.Key == "password" { + return slog.Attr{} + } + return a +} + +// When configured via gslog.WithReplaceAttr(), gslog.GcpHandler will apply +// the supplied gslog.AttrMapper to all non-group attributes before they +// are logged. +func ExampleNewGcpHandler_withReplaceAttr() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintJsonPayload), gslog.WithReplaceAttr(RemovePassword)) + l := slog.New(h) + l = l.WithGroup("pub") + l = l.With(slog.String("username", "user-12234"), slog.String("password", string(pw))) + + l.Info("How now brown cow?") + + // Output: {"message":"How now brown cow?","pub":{"username":"user-12234"}} +} + +// When configured via gslog.WithLogLeveler(), gslog.GcpHandler use the +// slog.Leveler for logging level enabled checks. +func ExampleNewGcpHandler_withLogLeveler() { + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintJsonPayload), gslog.WithLogLeveler(slog.LevelError)) + l := slog.New(h) + + l.Info("How now brown cow?") + l.Error("The rain in Spain lies mainly on the plane.") + + // Output: {"message":"The rain in Spain lies mainly on the plane."} +} + +// When configured via gslog.WithLogLevelFromEnvVar(), gslog.GcpHandler obtains +// its log level from tne environmental variable specified by the key. +func ExampleNewGcpHandler_withLogLevelFromEnvVar() { + const envVar = "FOO_LOG_LEVEL" + _ = os.Setenv(envVar, "ERROR") + defer func() { + _ = os.Unsetenv(envVar) + }() + + h := gslog.NewGcpHandler(gslog.LoggerFunc(PrintJsonPayload), gslog.WithLogLevelFromEnvVar(envVar)) + l := slog.New(h) + + l.Info("How now brown cow?") + l.Error("The rain in Spain lies mainly on the plane.") + + // Output: {"message":"The rain in Spain lies mainly on the plane."} +} + +// A default log level configured via gslog.WithDefaultLogLeveler(). +func ExampleNewGcpHandler_withDefaultLogLeveler() { + const envVar = "FOO_LOG_LEVEL" + + h := gslog.NewGcpHandler( + gslog.LoggerFunc(PrintJsonPayload), + gslog.WithLogLevelFromEnvVar(envVar), + gslog.WithDefaultLogLeveler(slog.LevelError), + ) + l := slog.New(h) + + l.Info("How now brown cow?") + l.Error("The rain in Spain lies mainly on the plane.") + + // Output: {"message":"The rain in Spain lies mainly on the plane."} +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..808b5aa --- /dev/null +++ b/go.mod @@ -0,0 +1,53 @@ +module m4o.io/gslog + +go 1.21 + +require ( + cloud.google.com/go/logging v1.9.0 + 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 v1.25.0 + go.opentelemetry.io/otel/trace v1.25.0 + google.golang.org/protobuf v1.33.0 +) + +require ( + cloud.google.com/go v0.112.2 // indirect + cloud.google.com/go/compute v1.24.0 // indirect + cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go/longrunning v0.5.5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // 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/golang/protobuf v1.5.4 // 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.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/metric v1.25.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/sync v0.6.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.17.0 // indirect + google.golang.org/api v0.170.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c // indirect + google.golang.org/grpc v1.62.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d416426 --- /dev/null +++ b/go.sum @@ -0,0 +1,210 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw= +cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= +cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= +cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= +cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= +cloud.google.com/go/logging v1.9.0 h1:iEIOXFO9EmSiTjDmfpbRjOxECO7R8C7b8IXUGOj7xZw= +cloud.google.com/go/logging v1.9.0/go.mod h1:1Io0vnZv4onoUnsVUQY3HZ3Igb1nBchky0A0y7BBBhE= +cloud.google.com/go/longrunning v0.5.5 h1:GOE6pZFdSrTb4KAiKnXsJBtlE6mEyaW44oKyMILWnOg= +cloud.google.com/go/longrunning v0.5.5/go.mod h1:WV2LAxD8/rg5Z1cNW6FJ/ZpX4E4VnDnoTk0yawPBB7s= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA= +github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.25.0 h1:gldB5FfhRl7OJQbUHt/8s0a7cE8fbsPAtdpRaApKy4k= +go.opentelemetry.io/otel v1.25.0/go.mod h1:Wa2ds5NOXEMkCmUou1WA7ZBfLTHWIsp034OVD7AO+Vg= +go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D4NuEwA= +go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s= +go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw= +go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc= +go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM= +go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= +golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.170.0 h1:zMaruDePM88zxZBG+NG8+reALO2rfLhe/JShitLyT48= +google.golang.org/api v0.170.0/go.mod h1:/xql9M2btF85xac/VAm4PsLMTLVGUOpq4BE9R8jyNy8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c h1:kaI7oewGK5YnVwj+Y+EJBO/YN1ht8iTL9XkFHtVZLsc= +google.golang.org/genproto/googleapis/api v0.0.0-20240314234333-6e1732d8331c/go.mod h1:VQW3tUculP/D4B+xVCo+VgSq8As6wA9ZjHl//pmk+6s= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c h1:lfpJ/2rWPa/kJgxyyXM8PrNnfCzcmxJ265mADgwmvLI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/gslog_suite_test.go b/gslog_suite_test.go new file mode 100644 index 0000000..62e61b6 --- /dev/null +++ b/gslog_suite_test.go @@ -0,0 +1,29 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGo(t *testing.T) { + RegisterFailHandler(Fail) + suiteConfig, reporterConfig := GinkgoConfiguration() + reporterConfig.Verbose = true + RunSpecs(t, "GCP Cloud Logging slog Handler Suite", suiteConfig, reporterConfig) +} diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..1e4b9bd --- /dev/null +++ b/handler.go @@ -0,0 +1,255 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" + "fmt" + "log/slog" + "os" + "runtime" + "slices" + + "cloud.google.com/go/logging" + logpb "cloud.google.com/go/logging/apiv2/loggingpb" + "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 is the key used for the message of the log call, per Google + // Cloud Logging. The associated value is a string. + 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 []options.EntryAugmentor + replaceAttr AttrMapper + + payload *spb.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: &spb.Struct{Fields: make(map[string]*spb.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 +} + +// Enabled reports whether the handler handles records at the given level. +// The handler ignores records whose level is lower. +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 *spb.Value as a Entry Payload. +func (h *GcpHandler) Handle(ctx context.Context, record slog.Record) error { + payload2 := proto.Clone(h.payload).(*spb.Struct) + if payload2.Fields == nil { + payload2.Fields = make(map[string]*spb.Value) + } + + setAndClean(h.groups, payload2, func(groups []string, payload *spb.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, h.groups) + } + + 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 +} + +// WithAttrs returns a copy of the handler whose attributes consists +// of h's attributes followed by attrs. +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 +} + +// WithGroup returns a copy of the handler with the given group +// appended to the receiver's existing groups. +func (h *GcpHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + var h2 = h.clone() + + h2.payload = proto.Clone(h.payload).(*spb.Struct) + + current := fromPath(h2.payload, h2.groups) + + current.Fields[name] = &spb.Value{ + Kind: &spb.Value_StructValue{ + StructValue: &spb.Struct{ + Fields: make(map[string]*spb.Value), + }, + }, + } + + h2.groups = append(h.groups, name) + + return h2 +} + +func (h *GcpHandler) Flush() { + +} + +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).(*spb.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 *spb.Struct, path []string) *spb.Struct { + for _, k := range path { + p = p.Fields[k].GetStructValue() + } + if p.Fields == nil { + p.Fields = make(map[string]*spb.Value) + } + return p +} + +func setAndClean(groups []string, payload *spb.Struct, decorate func(groups []string, payload *spb.Struct)) { + if len(groups) == 0 { + if payload.Fields == nil { + payload.Fields = make(map[string]*spb.Value) + } + + decorate(groups, payload) + return + } + + g := groups[0] + + s := payload.Fields[g].GetStructValue() + setAndClean(groups[1:], s, decorate) + + if len(s.Fields) == 0 { + delete(payload.Fields, g) + } +} diff --git a/handler_test.go b/handler_test.go new file mode 100644 index 0000000..8a7258f --- /dev/null +++ b/handler_test.go @@ -0,0 +1,1172 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "encoding/json" + "log/slog" + "path/filepath" + "runtime" + "slices" + "strings" + "sync" + "testing" + "time" + + "cloud.google.com/go/logging" + logpb "cloud.google.com/go/logging/apiv2/loggingpb" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog" + "m4o.io/gslog/internal/attr" + "m4o.io/gslog/internal/options" +) + +var testTime = time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC) + +type replace struct { + v slog.Value +} + +func (r *replace) LogValue() slog.Value { return r.v } + +type Got struct { + LogEntry logging.Entry + SyncLogEntry logging.Entry +} + +func (g *Got) Log(e logging.Entry) { + g.LogEntry = e +} + +func (g *Got) LogSync(_ context.Context, e logging.Entry) error { + g.SyncLogEntry = e + return nil +} + +func (g *Got) Flush() error { + return nil +} + +// callerPC returns the program counter at the given stack depth. +func callerPC(depth int) uintptr { + var pcs [1]uintptr + runtime.Callers(depth, pcs[:]) + return pcs[0] +} + +func TestDefaultHandle(t *testing.T) { + ctx := context.Background() + preAttrs := []slog.Attr{slog.Int("pre", 0)} + attrs := []slog.Attr{slog.Int("a", 1), slog.String("b", "two")} + for _, test := range []struct { + name string + with func(slog.Handler) slog.Handler + attrs []slog.Attr + want func() logging.Entry + }{ + { + name: "no attrs", + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "attrs", + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.String("b", "two")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 0)) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.String("b", "two")) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "groups", + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4)), + slog.Int("e", 5), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4))) + attr.DecorateWith(p, slog.Int("e", 5)) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "group", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs).WithGroup("s") }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 0)) + attr.DecorateWith(p, slog.Group("s", + slog.Int("a", 1), + slog.String("b", "two"))) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted groups", + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithAttrs([]slog.Attr{slog.Int("p2", 2)}). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Int("p2", 2), + slog.Group("s2", + slog.Int("a", 1), + slog.String("b", "two"), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "two with-groups", + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Group("s2", + slog.Int("a", 1), + slog.String("b", "two"), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: time.Time{}.UTC(), + Severity: logging.Info, + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + got := &Got{} + var h slog.Handler = gslog.NewGcpHandler(got, gslog.WithDefaultLogLeveler(slog.LevelInfo)) + if test.with != nil { + h = test.with(h) + } + r := slog.NewRecord(time.Time{}, slog.LevelInfo, "message", 0) + r.AddAttrs(test.attrs...) + if err := h.Handle(ctx, r); err != nil { + t.Fatal(err) + } + + want := test.want() + assert.Equal(t, want.Timestamp, got.LogEntry.Timestamp) + assert.Equal(t, want.Severity, got.LogEntry.Severity) + assert.True(t, proto.Equal(want.Payload.(proto.Message), got.LogEntry.Payload.(proto.Message))) + }) + } +} + +func TestConcurrentWrites(t *testing.T) { + const count = 1000 + + var mu sync.Mutex + var s1Count int + var s2Count int + var h slog.Handler = gslog.NewGcpHandler( + gslog.LoggerFunc(func(e logging.Entry) { + mu.Lock() + defer mu.Unlock() + + p := e.Payload.(*structpb.Struct) + if _, ok := p.Fields["sub1"]; ok { + s1Count++ + } + if _, ok := p.Fields["sub2"]; ok { + s2Count++ + } + }), + gslog.WithDefaultLogLeveler(slog.LevelInfo)) + + sub1 := h.WithAttrs([]slog.Attr{slog.Bool("sub1", true)}) + sub2 := h.WithAttrs([]slog.Attr{slog.Bool("sub2", true)}) + + ctx := context.Background() + var wg sync.WaitGroup + for i := 0; i < count; i++ { + sub1Record := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello from sub1", 0) + sub1Record.AddAttrs(slog.Int("i", i)) + sub2Record := slog.NewRecord(time.Time{}, slog.LevelInfo, "hello from sub2", 0) + sub2Record.AddAttrs(slog.Int("i", i)) + + wg.Add(1) + + go func() { + defer wg.Done() + if err := sub1.Handle(ctx, sub1Record); err != nil { + t.Error(err) + } + if err := sub2.Handle(ctx, sub2Record); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + + assert.Equal(t, count, s1Count) + assert.Equal(t, count, s2Count) +} + +// Verify the common parts of TextHandler and JSONHandler. +func TestJSONAndTextHandlers(t *testing.T) { + // remove all Attrs + removeAll := func(_ []string, a slog.Attr) slog.Attr { return slog.Attr{} } + + attrs := []slog.Attr{slog.String("a", "one"), slog.Int("b", 2), slog.Any("", nil)} + preAttrs := []slog.Attr{slog.Int("pre", 3), slog.String("x", "y")} + + for _, test := range []struct { + name string + replace func([]string, slog.Attr) slog.Attr + addSource *logpb.LogEntrySourceLocation + with func(slog.Handler) slog.Handler + preAttrs []slog.Attr + attrs []slog.Attr + want func() logging.Entry + }{ + { + name: "basic", + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty key", + attrs: append(slices.Clip(attrs), slog.Any("", "v")), + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + attr.DecorateWith(p, slog.Any("", "v")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "cap keys", + replace: upperCaseKey, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("MESSAGE", "message")) + attr.DecorateWith(p, slog.String("A", "one")) + attr.DecorateWith(p, slog.Int("B", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "remove all", + replace: removeAll, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted", + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted cap keys", + replace: upperCaseKey, + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("MESSAGE", "message")) + attr.DecorateWith(p, slog.Int("PRE", 3)) + attr.DecorateWith(p, slog.String("X", "y")) + attr.DecorateWith(p, slog.String("A", "one")) + attr.DecorateWith(p, slog.Int("B", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted remove all", + replace: removeAll, + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + preAttrs: preAttrs, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "remove built-in", + replace: removeKeys(gslog.MessageKey), + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted remove built-in", + replace: removeKeys(gslog.MessageKey), + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs) }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.String("a", "one")) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "groups", + replace: removeKeys(), // to simplify the result + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4)), + slog.Int("e", 5), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Group("g", + slog.Int("b", 2), + slog.Group("h", slog.Int("c", 3)), + slog.Int("d", 4))) + attr.DecorateWith(p, slog.Int("e", 5)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty group", + replace: removeKeys(), + attrs: []slog.Attr{slog.Group("g"), slog.Group("h", slog.Int("a", 1))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("h", + slog.Int("a", 1))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "nested empty group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j"))), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "nested non-empty group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j", slog.Int("a", 1)))), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("g", + slog.Group("h", + slog.Group("i"), slog.Group("j", slog.Int("a", 1))))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "escapes", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.String("a b", "x\t\n\000y"), + slog.Group(" b.c=\"\\x2E\t", + slog.String("d=e", "f.g\""), + slog.Int("m.d", 1)), // dot is not escaped + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("a b", "x\t\n\000y")) + attr.DecorateWith(p, slog.Group(" b.c=\"\\x2E\t", + slog.String("d=e", "f.g\""), + slog.Int("m.d", 1))) // dot is not escaped + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "LogValuer", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Any("name", logValueName{"Ren", "Hoek"}), + slog.Int("b", 2), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Any("name", logValueName{"Ren", "Hoek"})) + attr.DecorateWith(p, slog.Int("b", 2)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + // Test resolution when there is no ReplaceAttr function. + name: "resolve", + attrs: []slog.Attr{ + slog.Any("", &replace{slog.Value{}}), // should be elided + slog.Any("name", logValueName{"Ren", "Hoek"}), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Any("name", logValueName{"Ren", "Hoek"})) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "with-group", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { return h.WithAttrs(preAttrs).WithGroup("s") }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("pre", 3)) + attr.DecorateWith(p, slog.String("x", "y")) + attr.DecorateWith(p, slog.Group("s", + slog.String("a", "one"), + slog.Int("b", 2))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "preformatted with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithAttrs([]slog.Attr{slog.Int("p2", 2)}). + WithGroup("s2"). + WithAttrs([]slog.Attr{slog.Int("p3", 3)}) + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Int("p2", 2), + slog.Group("s2", + slog.Int("p3", 3), + slog.String("a", "one"), + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "two with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithAttrs([]slog.Attr{slog.Int("p1", 1)}). + WithGroup("s1"). + WithGroup("s2") + }, + attrs: attrs, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("p1", 1)) + attr.DecorateWith(p, slog.Group("s1", + slog.Group("s2", + slog.String("a", "one"), + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty with-groups", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithGroup("y") + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "with-group empty", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("").WithGroup("y").WithAttrs([]slog.Attr{slog.String("a", "one")}) + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("y", slog.String("a", "one"))) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "empty with-groups, no non-empty attrs", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithAttrs([]slog.Attr{slog.Group("g")}).WithGroup("y") + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "one empty with-group", + replace: removeKeys(), + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("x").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("y") + }, + attrs: []slog.Attr{slog.Group("g", slog.Group("h"))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Group("x", + slog.Int("a", 1), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "GroupValue as slog.Attr value", + replace: removeKeys(), + attrs: []slog.Attr{{"v", slog.AnyValue(slog.IntValue(3))}}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("v", 3)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "byte slice", + replace: removeKeys(), + attrs: []slog.Attr{slog.Any("bs", []byte{1, 2, 3, 4})}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.String("bs", "AQIDBA==")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "json.RawMessage", + replace: removeKeys(), + attrs: []slog.Attr{slog.Any("bs", json.RawMessage("1234"))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("bs", 1234)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "inline group", + replace: removeKeys(), + attrs: []slog.Attr{ + slog.Int("a", 1), + slog.Group("", slog.Int("b", 2), slog.Int("c", 3)), + slog.Int("d", 4), + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + attr.DecorateWith(p, slog.Int("a", 1)) + attr.DecorateWith(p, slog.Int("b", 2)) + attr.DecorateWith(p, slog.Int("c", 3)) + attr.DecorateWith(p, slog.Int("d", 4)) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "Source", + replace: func(gs []string, a slog.Attr) slog.Attr { + if a.Key == slog.SourceKey { + s := a.Value.Any().(*slog.Source) + s.File = filepath.Base(s.File) + return slog.Any(a.Key, s) + } + return removeKeys()(gs, a) + }, + addSource: &logpb.LogEntrySourceLocation{ + File: "gslog/handler_test.go", + Line: 1, + Function: "m4o.io/gslog_test.TestJSONAndTextHandlers", + }, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.String("message", "message")) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty", + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("g", slog.Int("a", 1))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 1", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("h", slog.Int("b", 2))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 2", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty 3", + with: func(h slog.Handler) slog.Handler { return h.WithGroup("g") }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Int("a", 1)}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace empty inline", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func([]string, slog.Attr) slog.Attr { return slog.Attr{} }, + attrs: []slog.Attr{slog.Group("", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 1", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Group("h", + slog.Int("b", 2), + slog.Group("i", + slog.Int("c", 3)), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 2", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g").WithAttrs([]slog.Attr{slog.Int("a", 1)}).WithAttrs([]slog.Attr{slog.Int("n", 4)}).WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a", "b")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Int("n", 4), + slog.Group("h", + slog.Group("i", + slog.Int("c", 3)), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace partial empty attrs 3", + with: func(h slog.Handler) slog.Handler { + return h.WithGroup("g"). + WithAttrs([]slog.Attr{slog.Int("x", 0)}). + WithAttrs([]slog.Attr{slog.Int("a", 1)}). + WithAttrs([]slog.Attr{slog.Int("n", 4)}). + WithGroup("h").WithAttrs([]slog.Attr{slog.Int("b", 2)}) + }, + replace: func(groups []string, attr slog.Attr) slog.Attr { + return removeKeys(gslog.MessageKey, "a", "c")(groups, attr) + }, + attrs: []slog.Attr{slog.Group("i", slog.Int("c", 3))}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("g", + slog.Int("x", 0), + slog.Int("n", 4), + slog.Group("h", + slog.Int("b", 2), + ), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + { + name: "replace resolved group", + replace: func(groups []string, a slog.Attr) slog.Attr { + if a.Value.Kind() == slog.KindGroup { + return slog.Attr{Key: "bad", Value: slog.IntValue(1)} + } + return removeKeys(gslog.MessageKey)(groups, a) + }, + attrs: []slog.Attr{slog.Any("name", logValueName{"Perry", "Platypus"})}, + want: func() logging.Entry { + p := &structpb.Struct{Fields: make(map[string]*structpb.Value)} + attr.DecorateWith(p, slog.Group("name", + slog.String("first", "Perry"), + slog.String("last", "Platypus"), + )) + + return logging.Entry{ + Payload: p, + Timestamp: testTime.UTC(), + Severity: logging.Info, + } + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + r := slog.NewRecord(testTime, slog.LevelInfo, "message", callerPC(2)) + line := source(r).Line + r.AddAttrs(test.attrs...) + + var opts = []options.OptionProcessor{ + gslog.WithReplaceAttr(test.replace), + gslog.WithDefaultLogLeveler(slog.LevelInfo), + } + + if test.addSource != nil { + opts = append(opts, gslog.WithSourceAdded()) + } + + got := &Got{} + var h slog.Handler = gslog.NewGcpHandler(got, opts...) + + if test.with != nil { + h = test.with(h) + } + + if err := h.Handle(context.Background(), r); err != nil { + t.Fatal(err) + } + + if test.want == nil { + return + } + want := test.want() + assert.Equal(t, want.Timestamp, got.LogEntry.Timestamp) + assert.Equal(t, want.Severity, got.LogEntry.Severity) + assert.True(t, proto.Equal(want.Payload.(proto.Message), got.LogEntry.Payload.(proto.Message))) + + if test.addSource != nil { + actual := got.LogEntry.SourceLocation + expected := test.addSource + assert.Equal(t, int64(line), actual.Line) + assert.Equal(t, expected.File, actual.File[len(actual.File)-len(expected.File):]) + assert.Equal(t, expected.Function, actual.Function[0:len(expected.Function)]) + } + }) + } +} + +func TestWithLeveler(t *testing.T) { + got := &Got{} + var h = gslog.NewGcpHandler(got, gslog.WithLogLeveler(slog.LevelInfo)) + + l := slog.New(h.WithLeveler(slog.LevelError)) + + l.Debug("How now brown cow") + + assert.Nil(t, got.LogEntry.Payload) + + l.Error("Ouch!") + + assert.NotNil(t, got.LogEntry.Payload) +} + +func TestLevelCritical(t *testing.T) { + got := &Got{} + var h = gslog.NewGcpHandler(got, gslog.WithLogLeveler(slog.LevelInfo)) + l := slog.New(h) + + l.Info("How now brown cow") + + assert.NotNil(t, got.LogEntry.Payload) + assert.Nil(t, got.SyncLogEntry.Payload) + + got.LogEntry = logging.Entry{} + + l.Log(context.Background(), gslog.LevelCritical, "Ouch!") + assert.Nil(t, got.LogEntry.Payload) + assert.NotNil(t, got.SyncLogEntry.Payload) +} + +// removeKeys returns a function suitable for HandlerOptions.ReplaceAttr +// that removes all Attrs with the given keys. +func removeKeys(keys ...string) func([]string, slog.Attr) slog.Attr { + return func(_ []string, a slog.Attr) slog.Attr { + for _, k := range keys { + if a.Key == k { + return slog.Attr{} + } + } + return a + } +} + +func upperCaseKey(_ []string, a slog.Attr) slog.Attr { + a.Key = strings.ToUpper(a.Key) + return a +} + +type logValueName struct { + first, last string +} + +func (n logValueName) LogValue() slog.Value { + return slog.GroupValue( + slog.String("first", n.first), + slog.String("last", n.last)) +} + +func source(r slog.Record) *slog.Source { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + return &slog.Source{ + Function: f.Function, + File: f.File, + Line: f.Line, + } +} diff --git a/internal/attr/attr.go b/internal/attr/attr.go new file mode 100644 index 0000000..1027cad --- /dev/null +++ b/internal/attr/attr.go @@ -0,0 +1,238 @@ +// 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" + + 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 = &spb.Value{Kind: &spb.Value_NullValue{NullValue: spb.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 spb.Struct's Fields. If the +// attribute cannot be mapped to a spb.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 spb.Value, that value is +// used. +// - If the attribute can be converted into a JSON object, that JSON object is +// translated to its corresponding spb.Struct. +// - Nothing is done. +func DecorateWith(p *spb.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 *spb.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 &spb.Value{Kind: &spb.Value_StringValue{StringValue: err.Error()}}, true + } + + // value may be simply mappable to a spb.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 *spb.Value) { + val, ok := ValToStruct(v) + if !ok { + panic("expected everything to be ok") + } + return val +} + +func NewStringValue(str string) *spb.Value { + return &spb.Value{Kind: &spb.Value_StringValue{StringValue: str}} +} + +func NewNumberValue(val float64) *spb.Value { + return &spb.Value{Kind: &spb.Value_NumberValue{NumberValue: val}} +} + +func NewBoolValue(b bool) *spb.Value { + return &spb.Value{Kind: &spb.Value_BoolValue{BoolValue: b}} +} + +func NewGroupValue(g []slog.Attr) *spb.Value { + p := &spb.Struct{Fields: make(map[string]*spb.Value)} + for _, b := range g { + DecorateWith(p, b) + } + return &spb.Value{Kind: &spb.Value_StructValue{StructValue: p}} +} + +func NewTimeValue(t time.Time) *spb.Value { + return &spb.Value{Kind: &spb.Value_StringValue{StringValue: AppendRFC3339Millis(t)}} +} + +// AsJson attempts to convert the attribute a to a corresponding spb.Value +// by first converted to a JSON object and then mapping that JSON object to a +// corresponding spb.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 *spb.Value, ok bool) { + if a == nil { + return NilValue, true + } + + a, err := ToJson(a) + if err != nil { + return nil, false + } + + nv, _ := spb.NewValue(a) + + return nv, true +} + +func ToJson(a any) (any, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + if err := enc.Encode(a); err != nil { + return nil, err + } + + var result any + _ = json.Unmarshal(buf.Bytes(), &result) + + return result, nil +} + +func AppendRFC3339Millis(t time.Time) string { + ptr := timePool.Get().(*[]byte) + buf := *ptr + buf = buf[0:0] + defer func() { + *ptr = buf + timePool.Put(ptr) + }() + buf = append(buf, byte('"')) + + // Format according to time.RFC3339Nano since it is highly optimized, + // but truncate it to use millisecond resolution. + // Unfortunately, that format trims trailing 0s, so add 1/10 millisecond + // to guarantee that there are exactly 4 digits after the period. + const prefixLen = len("2006-01-02T15:04:05.000") + n := len(buf) + t = t.Truncate(time.Millisecond).Add(time.Millisecond / 10) + buf = t.AppendFormat(buf, time.RFC3339Nano) + buf = append(buf[:n+prefixLen], buf[n+prefixLen+1:]...) // drop the 4th digit + + buf = append(buf, byte('"')) + + return string(buf) +} diff --git a/internal/attr/attr_test.go b/internal/attr/attr_test.go new file mode 100644 index 0000000..70a8cc2 --- /dev/null +++ b/internal/attr/attr_test.go @@ -0,0 +1,318 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package attr_test + +import ( + "errors" + "fmt" + "log/slog" + "math" + "reflect" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog/internal/attr" +) + +type Circular struct { + Self *Circular `json:"self"` +} + +type Manager struct { +} + +type Password string + +func (p Password) MarshalJSON() ([]byte, error) { + return []byte(strconv.Quote("")), nil +} + +func (p Password) LogValue() slog.Value { + return pwObfuscated +} + +type User struct { + ID string `json:"id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Email string `json:"email"` + Password Password `json:"password"` + Age uint8 `json:"age"` + Height float32 `json:"height"` + Engineer bool `json:"engineer"` + Manager *Manager `json:"manager"` +} + +type Chimera struct { + Name string `json:"name"` +} + +func (u *Chimera) MarshalJSON() ([]byte, error) { + return []byte(fmt.Sprintf(`{"name":"%s"}`, u.Name)), nil +} + +// Error should never be called since +func (u *Chimera) Error() string { + panic("ouch") +} + +var ( + pw = Password("pass-12334") + pwObfuscated = slog.StringValue("") + u = &User{ + ID: "user-12234", + FirstName: "Jan", + LastName: "Doe", + Email: "jan@example.com", + Password: pw, + Age: 32, + Height: 5.91, + Engineer: true, + } + + uJson = map[string]interface{}{ + "id": "user-12234", + "first_name": "Jan", + "last_name": "Doe", + "email": "jan@example.com", + "password": "", + "age": float64(32), + "height": 5.91, + "engineer": true, + "manager": nil, + } + + uStruct *structpb.Value + + uGroup []slog.Attr + + circular *Circular + + chimera = &Chimera{Name: "Pookie Bear"} + cStruct = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "name": { + Kind: &structpb.Value_StringValue{StringValue: "Pookie Bear"}, + }, + }, + }, + }, + } +) + +func init() { + circular = &Circular{} + circular.Self = circular + + fields := make(map[string]*structpb.Value) + fields["id"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "user-12234"}} + fields["first_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Jan"}} + fields["last_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Doe"}} + fields["email"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "jan@example.com"}} + fields["password"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: ""}} + fields["age"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(32)}} + fields["height"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: 5.91}} + fields["engineer"] = &structpb.Value{Kind: &structpb.Value_BoolValue{BoolValue: true}} + fields["manager"] = attr.NilValue + uStruct = &structpb.Value{ + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: fields, + }, + }, + } + + uGroup = append(uGroup, slog.String("id", "user-12234")) + uGroup = append(uGroup, slog.String("first_name", "Jan")) + uGroup = append(uGroup, slog.String("last_name", "Doe")) + uGroup = append(uGroup, slog.String("email", "jan@example.com")) + uGroup = append(uGroup, slog.Any("password", pw)) + uGroup = append(uGroup, slog.Uint64("age", 32)) + uGroup = append(uGroup, slog.Float64("height", 5.91)) + uGroup = append(uGroup, slog.Bool("engineer", true)) + uGroup = append(uGroup, slog.Any("manager", nil)) +} + +func TestToJson(t *testing.T) { + tests := map[string]struct { + attr any + json any + err bool + }{ + "ok": {u, uJson, false}, + "simple": {"cow", "cow", false}, + "error": {circular, nil, true}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + json, err := attr.ToJson(tc.attr) + if tc.err { + assert.Error(t, err) + } else { + assert.Equal(t, tc.json, json) + } + }) + } +} + +func TestAsJson(t *testing.T) { + tests := map[string]struct { + attr any + value *structpb.Value + ok bool + }{ + "nil": {nil, attr.NilValue, true}, + "not simple": {u, uStruct, true}, + "error": {circular, nil, false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + value, ok := attr.AsJson(tc.attr) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.value, value) + } + }) + } +} + +func TestValToStruct(t *testing.T) { + now := time.Now().UTC() + tests := map[string]struct { + attr slog.Value + value *structpb.Value + ok bool + }{ + "nil": {slog.AnyValue(nil), attr.NilValue, true}, + "string": {slog.StringValue("how now brown cow"), attr.NewStringValue("how now brown cow"), true}, + "int64": {slog.Int64Value(math.MaxInt64), attr.NewNumberValue(float64(math.MaxInt64)), true}, + "uint64": {slog.Uint64Value(math.MaxUint64), attr.NewNumberValue(float64(math.MaxUint64)), true}, + "float64": {slog.Float64Value(math.MaxFloat64), attr.NewNumberValue(math.MaxFloat64), true}, + "bool true": {slog.BoolValue(true), attr.NewBoolValue(true), true}, + "bool false": {slog.BoolValue(false), attr.NewBoolValue(false), true}, + "duration": {slog.DurationValue(time.Minute * 5), attr.NewNumberValue(float64(time.Minute * 5)), true}, + "time": {slog.TimeValue(now), attr.NewTimeValue(now), true}, + "group": {slog.GroupValue(uGroup...), uStruct, true}, + "group empty": {slog.GroupValue(), nil, false}, + "any LogValuer": {slog.AnyValue(pw), nil, false}, // this should have been transformed earlier via Resolve() + "any resolved LogValuer": {slog.AnyValue(pw).Resolve(), attr.NewStringValue(""), true}, + "any JSON": {slog.AnyValue(u), uStruct, true}, + "any json.Marshaler": {slog.AnyValue(chimera), cStruct, true}, + "any error": {slog.AnyValue(errors.New("ouch")), attr.NewStringValue("ouch"), true}, + "error": {slog.AnyValue(circular), nil, false}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + value, ok := attr.ValToStruct(tc.attr) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.value, value) + } + }) + } +} + +type groupMapper func(groups []string, a slog.Attr) slog.Attr + +type mapper func(a slog.Attr) slog.Attr + +func removeMapper(_ slog.Attr) slog.Attr { + return slog.Attr{} +} + +func genReplace(r slog.Attr, groups ...string) groupMapper { + return func(g []string, a slog.Attr) slog.Attr { + if reflect.DeepEqual(groups, g) { + return r + } + return a + } +} + +func genMapper(mapper mapper, groups []string, keys ...string) groupMapper { + return func(g []string, a slog.Attr) slog.Attr { + for _, key := range keys { + if reflect.DeepEqual(groups, g) && a.Key == key { + return mapper(a) + } + } + return a + } +} + +func groups(groups ...string) []string { + return groups +} + +func TestWrapAttrMapper(t *testing.T) { + tests := map[string]struct { + groups []string + attr slog.Attr + mapper groupMapper + expected slog.Attr + }{ + "simple replacement": {nil, slog.Int("a", 1), genReplace(slog.Int("b", 2)), slog.Int("b", 2)}, + "inside group": {groups("g", "h"), slog.Int("a", 1), genReplace(slog.Int("b", 2), "g", "h"), slog.Int("b", 2)}, + "with group": {groups("g"), slog.Group("h", slog.Int("a", 1)), genReplace(slog.Int("b", 2), "g", "h"), slog.Group("h", slog.Int("b", 2))}, + "group replace": {groups("g"), slog.Group("h", slog.Int("a", 1), slog.Int("b", 2)), genMapper(removeMapper, groups("g", "h"), "a"), slog.Group("h", slog.Int("b", 2))}, + "group replace empty": {groups("g"), slog.Group("h", slog.Int("a", 1)), genReplace(slog.Attr{}, "g", "h"), slog.Attr{}}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + m := attr.WrapAttrMapper(tc.mapper) + actual := m(tc.groups, tc.attr) + + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestWrapAttrMapper_nil(t *testing.T) { + assert.Nil(t, attr.WrapAttrMapper(nil)) +} + +const rfc3339Millis = "2006-01-02T15:04:05.000Z07:00" + +func TestWriteTimeRFC3339(t *testing.T) { + for _, tm := range []time.Time{ + time.Date(2000, 1, 2, 3, 4, 5, 0, time.UTC), + time.Date(2000, 1, 2, 3, 4, 5, 400, time.Local), + time.Date(2000, 11, 12, 3, 4, 500, 5e7, time.UTC), + } { + got := attr.AppendRFC3339Millis(tm) + want := `"` + tm.Format(rfc3339Millis) + `"` + if got != want { + t.Errorf("got %s, want %s", got, want) + } + } +} + +func BenchmarkWriteTime(b *testing.B) { + tm := time.Date(2022, 3, 4, 5, 6, 7, 823456789, time.Local) + b.ResetTimer() + for i := 0; i < b.N; i++ { + attr.AppendRFC3339Millis(tm) + } +} diff --git a/internal/level/level.go b/internal/level/level.go new file mode 100644 index 0000000..4a9766f --- /dev/null +++ b/internal/level/level.go @@ -0,0 +1,30 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package level + +import ( + "log/slog" + + "cloud.google.com/go/logging" +) + +// LevelToSeverity converts slog.Level logging levels to logging.Severity. +func LevelToSeverity(level slog.Level) logging.Severity { + severity := logging.Severity((int(level) + 8) / 4 * 100) + if slog.LevelInfo < level { + return severity + 100 + } + return severity +} diff --git a/internal/level/level_suite_test.go b/internal/level/level_suite_test.go new file mode 100644 index 0000000..3bbdaf7 --- /dev/null +++ b/internal/level/level_suite_test.go @@ -0,0 +1,29 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package level_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, "Log level Suite", suiteConfig, reporterConfig) +} diff --git a/internal/level/level_test.go b/internal/level/level_test.go new file mode 100644 index 0000000..9aa8d68 --- /dev/null +++ b/internal/level/level_test.go @@ -0,0 +1,41 @@ +// 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_test + +import ( + "log/slog" + + "cloud.google.com/go/logging" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "m4o.io/gslog" + "m4o.io/gslog/internal/level" +) + +var _ = DescribeTable("Mapping slog.Level to logging.Severity", + func(lvl slog.Level, expected logging.Severity) { + Ω(level.LevelToSeverity(lvl)).Should(Equal(expected)) + }, + Entry("trace", slog.Level(-8), logging.Severity(0)), + Entry("debug", slog.LevelDebug, logging.Debug), + Entry("info", slog.LevelInfo, logging.Info), + Entry("notice", gslog.LevelNotice, logging.Notice), + Entry("warn", slog.LevelWarn, logging.Warning), + Entry("error", slog.LevelError, logging.Error), + Entry("critical", gslog.LevelCritical, logging.Critical), + Entry("alert", gslog.LevelAlert, logging.Alert), + Entry("emergency", gslog.LevelEmergency, logging.Emergency), +) diff --git a/internal/options/option.go b/internal/options/option.go new file mode 100644 index 0000000..6730716 --- /dev/null +++ b/internal/options/option.go @@ -0,0 +1,107 @@ +// 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) +) + +type EntryAugmentor func(ctx context.Context, e *logging.Entry, groups []string) + +// Options holds information needed to construct an instance of GcpHandler. +type Options struct { + ExplicitLogLevel slog.Leveler + EnvVarLogLevel slog.Level + DefaultLogLevel slog.Leveler + + EntryAugmentors []EntryAugmentor + + // AddSource causes the handler to compute the source code position + // of the log statement and add a SourceKey attribute to the output. + AddSource bool + + // Level reports the minimum record level that will be logged. + // The handler discards records with lower levels. + // If Level is nil, the handler assumes LevelInfo. + // The handler calls Level.Level() for each record processed; + // to adjust the minimum level dynamically, use a LevelVar. + Level slog.Leveler + + // ReplaceAttr is called to rewrite each non-group attribute before it is logged. + // The attribute's value has been resolved (see [Value.Resolve]). + // If ReplaceAttr returns a zero Attr, the attribute is discarded. + // + // The built-in attributes with keys "time", "level", "source", and "msg" + // are passed to this function, except that time is omitted + // if zero, and source is omitted if addSource is false. + // + // The first argument is a list of currently open groups that contain the + // Attr. It must not be retained or modified. ReplaceAttr is never called + // for Group attributes, only their contents. For example, the attribute + // list + // + // Int("a", 1), Group("g", Int("b", 2)), Int("c", 3) + // + // results in consecutive calls to ReplaceAttr with the following arguments: + // + // nil, Int("a", 1) + // []string{"g"}, Int("b", 2) + // nil, Int("c", 3) + // + // ReplaceAttr can be used to change the default keys of the built-in + // attributes, convert types (for example, to replace a `time.Time` with the + // integer seconds since the Unix epoch), sanitize personal information, or + // remove attributes from the output. + ReplaceAttr func(groups []string, a slog.Attr) slog.Attr +} + +type OptionProcessor func(o *Options) + +func ApplyOptions(opts ...OptionProcessor) *Options { + o := &Options{ + EnvVarLogLevel: levelUnknown, + ExplicitLogLevel: levelUnknown, + DefaultLogLevel: levelUnknown, + } + for _, opt := range opts { + opt(o) + } + + o.Level = o.DefaultLogLevel + if o.EnvVarLogLevel != levelUnknown { + o.Level = o.EnvVarLogLevel + } + if o.ExplicitLogLevel != levelUnknown { + o.Level = o.ExplicitLogLevel + } + if o.Level == levelUnknown { + o.Level = slog.LevelInfo + } + + return o +} diff --git a/k8s/podinfo.go b/k8s/podinfo.go new file mode 100644 index 0000000..0625301 --- /dev/null +++ b/k8s/podinfo.go @@ -0,0 +1,81 @@ +// 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 k8s contains options for including labels from the Kubernetes Downward +API podinfo labels file in logging records. + +Placing the options in a separate package minimizes the dependencies pulled in +by those who do not need labels from the Kubernetes Downward API. +*/ +package k8s + +import ( + "context" + "log/slog" + "os" + "path/filepath" + + "cloud.google.com/go/logging" + "github.com/magiconair/properties" + + "m4o.io/gslog/internal/options" +) + +const ( + // PodPrefix is the prefix for labels obtained from the Kubernetes + // Downward API podinfo labels file. + PodPrefix = "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) options.EntryAugmentor { + 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 func(_ context.Context, _ *logging.Entry, _ []string) {} + } + + return func(ctx context.Context, e *logging.Entry, _ []string) { + if e.Labels == nil { + e.Labels = make(map[string]string) + } + + for k, v := range p.Map() { + if v[0] == '"' { + v = v[1 : len(v)-1] + } + + key := PodPrefix + k + e.Labels[key] = v + } + } +} diff --git a/k8s/podinfo_suite_test.go b/k8s/podinfo_suite_test.go new file mode 100644 index 0000000..7f4d3ef --- /dev/null +++ b/k8s/podinfo_suite_test.go @@ -0,0 +1,29 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s_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, "Kubernetes Podinfo Suite", suiteConfig, reporterConfig) +} diff --git a/k8s/podinfo_test.go b/k8s/podinfo_test.go new file mode 100644 index 0000000..2e37012 --- /dev/null +++ b/k8s/podinfo_test.go @@ -0,0 +1,96 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package k8s_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/internal/options" + "m4o.io/gslog/k8s" +) + +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() { + k8s.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, nil) + } + + Ω(e.Labels).Should(MatchAllKeys(Keys{ + k8s.PodPrefix + "app": Equal("hello-world"), + k8s.PodPrefix + "environment": Equal("stg"), + k8s.PodPrefix + "tier": Equal("backend"), + k8s.PodPrefix + "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, nil) + } + + Ω(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, nil) + } + + Ω(e.Labels).Should(BeEmpty()) + }) + }) +}) diff --git a/k8s/testdata/etc/podinfo/labels b/k8s/testdata/etc/podinfo/labels new file mode 100644 index 0000000..1da6341 --- /dev/null +++ b/k8s/testdata/etc/podinfo/labels @@ -0,0 +1,4 @@ +app="hello-world" +environment="stg" +tier="backend" +track="stable" diff --git a/k8s/testdata/ouch/podinfo/labels b/k8s/testdata/ouch/podinfo/labels new file mode 100644 index 0000000..44e48db --- /dev/null +++ b/k8s/testdata/ouch/podinfo/labels @@ -0,0 +1,2 @@ +a="${b}" +b="${a}" diff --git a/labels.go b/labels.go new file mode 100644 index 0000000..eedab6a --- /dev/null +++ b/labels.go @@ -0,0 +1,71 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "context" +) + +// LabelPair represents a key-value string pair. +type LabelPair struct { + valid bool + key string + val string +} + +// Label returns a new LabelPair from a key and a value. +func Label(key, value string) LabelPair { + return LabelPair{valid: true, key: key, val: value} +} + +type labelsKey struct{} + +type labeler func(ctx context.Context, lbls map[string]string) + +func doNothing(context.Context, map[string]string) {} + +// WithLabels returns a new Context with labels to be used in the GCP log +// entries produced using that context. +func WithLabels(ctx context.Context, labels ...LabelPair) context.Context { + parent := labelsFrom(ctx) + return context.WithValue(ctx, labelsKey{}, + labeler(func(ctx context.Context, lbls map[string]string) { + parent(ctx, lbls) + for _, l := range labels { + if !l.valid { + panic("invalid label passed to WithLabels()") + } + lbls[l.key] = l.val + } + }), + ) +} + +// ExtractLabels extracts labels from the ctx. These labels were associated +// with the context using WithLabels. +func ExtractLabels(ctx context.Context) map[string]string { + labels := make(map[string]string) + lblr := labelsFrom(ctx) + lblr(ctx, labels) + return labels +} + +func labelsFrom(ctx context.Context) labeler { + v, ok := ctx.Value(labelsKey{}).(labeler) + if !ok { + return doNothing + } + return v +} diff --git a/labels_test.go b/labels_test.go new file mode 100644 index 0000000..3888ca7 --- /dev/null +++ b/labels_test.go @@ -0,0 +1,116 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "context" + "fmt" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "m4o.io/gslog" +) + +var _ = Describe("gslog labels", func() { + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + }) + + When("context is initialized with bad labels", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.LabelPair{}) + }) + + It("should panic when extracting from the context", func() { + Ω(func() { + gslog.ExtractLabels(ctx) + }).Should(PanicWith("invalid label passed to WithLabels()")) + }) + }) + + When("context is initialized with several labels", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.Label("how", "now"), gslog.Label("brown", "cow")) + }) + + It("they can be extracted from the context", func() { + lbls := gslog.ExtractLabels(ctx) + + Ω(lbls).Should(HaveLen(2)) + Ω(lbls).Should(HaveKeyWithValue("how", "now")) + Ω(lbls).Should(HaveKeyWithValue("brown", "cow")) + }) + + Context("and a label overridden", func() { + BeforeEach(func() { + ctx = gslog.WithLabels(ctx, gslog.Label("brown", "cat")) + }) + + It("the overrides can be extracted from the context", func() { + lbls := gslog.ExtractLabels(ctx) + + Ω(lbls).Should(HaveLen(2)) + Ω(lbls).Should(HaveKeyWithValue("how", "now")) + Ω(lbls).Should(HaveKeyWithValue("brown", "cat")) + }) + }) + }) +}) + +const ( + count = 10 +) + +var ( + labels map[string]string + ctx context.Context +) + +type mockKey struct{} + +func init() { + labels = make(map[string]string, count) + for i := 1; i <= count; i++ { + key := fmt.Sprintf("key_%06d", i) + value := fmt.Sprintf("val_%06d", i) + + labels[key] = value + } + + ctx = context.Background() + + for i := 1; i <= count; i++ { + 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 := 1; i <= count; i++ { + k := fmt.Sprintf("key_%06d", i) + v := fmt.Sprintf("val_%06d", i) + + ctx = gslog.WithLabels(ctx, gslog.Label(k, v)) + ctx = context.WithValue(ctx, mockKey{}, v) + } +} + +func BenchmarkExtractLabels(b *testing.B) { + gslog.ExtractLabels(ctx) +} diff --git a/logger.go b/logger.go new file mode 100644 index 0000000..27081e7 --- /dev/null +++ b/logger.go @@ -0,0 +1,86 @@ +// 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 + + // Flush blocks until all currently buffered log entries are sent. + // + // If any errors occurred since the last call to Flush from any Logger, or the + // creation of the client if this is the first call, then Flush returns a non-nil + // error with summary information about the errors. This information is unlikely to + // be actionable. For more accurate error reporting, set Client.OnError. + Flush() error +} + +type Log interface { + + // Log buffers the Entry for output to the logging service. It never blocks. + Log(e logging.Entry) +} + +type LogSync interface { + + // LogSync logs the Entry synchronously without any buffering. Because LogSync is slow + // and will block, it is intended primarily for debugging or critical errors. + // Prefer Log for most uses. + LogSync(ctx context.Context, e logging.Entry) error +} + +// The LoggerFunc type is an adapter to allow the use of +// ordinary functions as a Logger. If fn is a function +// with the appropriate signature, LoggerFunc(fn) is a +// Logger that calls fn. +type LoggerFunc func(e logging.Entry) + +func (fn LoggerFunc) Log(e logging.Entry) { + fn(e) +} + +func (fn LoggerFunc) LogSync(_ context.Context, e logging.Entry) error { + fn(e) + return nil +} + +func (fn LoggerFunc) Flush() error { + 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 +} + +func (d discard) Flush() error { + return nil +} + +var Discard Logger = discard{} diff --git a/logger_test.go b/logger_test.go new file mode 100644 index 0000000..731cc6e --- /dev/null +++ b/logger_test.go @@ -0,0 +1,62 @@ +// 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 TestLoggerFunc_Log(t *testing.T) { + var called bool + + l := gslog.LoggerFunc(func(e logging.Entry) { + called = true + }) + + l.Log(logging.Entry{}) + + assert.True(t, called) +} + +func TestLoggerFunc_LogSync(t *testing.T) { + var called bool + + l := gslog.LoggerFunc(func(e logging.Entry) { + called = true + }) + + ctx := context.Background() + err := l.LogSync(ctx, logging.Entry{}) + + assert.NoError(t, err) + assert.True(t, called) +} + +func TestDiscard_Log(t *testing.T) { + l := gslog.Discard + l.Log(logging.Entry{}) +} + +func TestDiscard_LogSync(t *testing.T) { + l := gslog.Discard + err := l.LogSync(context.Background(), logging.Entry{}) + assert.NoError(t, err) +} diff --git a/option.go b/option.go new file mode 100644 index 0000000..0139cb6 --- /dev/null +++ b/option.go @@ -0,0 +1,101 @@ +// 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 +} + +// WithLogLeveler returns an option that specifies the slog.Leveler for logging. +// Explicitly setting the log level here takes precedence over the other +// options. +func WithLogLeveler(logLevel slog.Leveler) 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") + } + + var envVarLogLevel slog.Level + + setLogLevel := func(o *options.Options) { + o.EnvVarLogLevel = envVarLogLevel + } + + s, ok := os.LookupEnv(key) + if !ok { + return func(o *options.Options) {} + } + i, err := strconv.Atoi(s) + if err == nil { + envVarLogLevel = slog.Level(i) + return setLogLevel + } + + switch s { + case "DEBUG": + envVarLogLevel = slog.LevelDebug + case "INFO": + envVarLogLevel = slog.LevelInfo + case "WARN": + envVarLogLevel = slog.LevelWarn + case "ERROR": + envVarLogLevel = slog.LevelError + default: + envVarLogLevel = slog.LevelInfo + } + + return setLogLevel +} + +// WithDefaultLogLeveler returns an option that specifies the default +// slog.Leveler for logging. +func WithDefaultLogLeveler(defaultLogLevel slog.Leveler) options.OptionProcessor { + return func(o *options.Options) { + o.DefaultLogLevel = defaultLogLevel + } +} + +// WithSourceAdded returns an option that causes the handler to compute the +// source code position of the log statement and add a slog.SourceKey attribute +// to the output. +func WithSourceAdded() options.OptionProcessor { + return func(o *options.Options) { + o.AddSource = true + } +} + +// WithReplaceAttr returns an option that specifies an attribute mapper used to +// rewrite each non-group attribute before it is logged. +func WithReplaceAttr(replaceAttr AttrMapper) options.OptionProcessor { + return func(o *options.Options) { + o.ReplaceAttr = replaceAttr + } +} diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..2780b8e --- /dev/null +++ b/option_test.go @@ -0,0 +1,109 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog_test + +import ( + "log/slog" + "math" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "m4o.io/gslog" + "m4o.io/gslog/internal/options" +) + +const ( + naString = "" + envVarLogLevelKey = "TEST_ENV_VAR" + levelUnknown = slog.Level(math.MaxInt) +) + +func TestLogLevel(t *testing.T) { + tests := map[string]struct { + explicitLogLevel slog.Level + defaultLogLevel slog.Level + envVar bool + envVarKey string + envVarValue string + expected slog.Level + }{ + "do nothing": {levelUnknown, levelUnknown, false, naString, naString, slog.LevelInfo}, + "default": {levelUnknown, slog.LevelInfo, false, naString, naString, slog.LevelInfo}, + "default missing env var": {levelUnknown, slog.LevelInfo, true, naString, naString, slog.LevelInfo}, + "explicit": {slog.LevelInfo, levelUnknown, false, naString, naString, slog.LevelInfo}, + "explicit overrides env var": {slog.LevelInfo, levelUnknown, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "explicit overrides default": {slog.LevelInfo, slog.LevelDebug, false, naString, naString, slog.LevelInfo}, + "explicit overrides all": {slog.LevelInfo, slog.LevelDebug, true, envVarLogLevelKey, "ERROR", slog.LevelInfo}, + "env var garbage": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "OUCH", slog.LevelInfo}, + "env var DEBUG": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "DEBUG", slog.LevelDebug}, + "env var INFO": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "env var WARN": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "WARN", slog.LevelWarn}, + "env var ERROR": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "ERROR", slog.LevelError}, + "env var missing": {levelUnknown, levelUnknown, true, naString, naString, slog.LevelInfo}, + "env var overrides default": {levelUnknown, slog.LevelDebug, true, envVarLogLevelKey, "INFO", slog.LevelInfo}, + "env var high custom level": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "32", slog.Level(32)}, + "env var low custom level": {levelUnknown, levelUnknown, true, envVarLogLevelKey, "-8", slog.Level(-8)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var opts []options.OptionProcessor + if tc.explicitLogLevel != levelUnknown { + opts = append(opts, gslog.WithLogLeveler(tc.explicitLogLevel)) + } + if tc.defaultLogLevel != levelUnknown { + opts = append(opts, gslog.WithDefaultLogLeveler(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.WithDefaultLogLeveler(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.WithDefaultLogLeveler(slog.LevelInfo)) + assert.Equal(t, s, o.ReplaceAttr(nil, slog.String("unused", "string"))) +} diff --git a/otel/baggage.go b/otel/baggage.go new file mode 100644 index 0000000..fff9f82 --- /dev/null +++ b/otel/baggage.go @@ -0,0 +1,161 @@ +// 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 + +import ( + "context" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/baggage" + spb "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog/internal/attr" + "m4o.io/gslog/internal/options" +) + +// noinspection GoNameStartsWithPackageName +const ( + // OtelBaggageKey is the prefix for keys obtained from the OpenTelemetry + // Baggage to mitigate collision with other log attributes. + OtelBaggageKey = "otel-baggage/" +) + +// WithOtelBaggage returns an gslog option that directs that the slog.Handler +// to include OpenTelemetry baggage. The baggage.Baggage is obtained from the +// context, if available, and added as attributes. +// +// The baggage keys are prefixed with "otel-baggage/" to mitigate collision +// with other log attributes. Baggage that have no properties are mapped to +// an slog.Attr for a string value. Baggage that have properties mapped to a +// slog.Group with two keys, "value" which is the value of the baggage, and +// "properties" which is the properties of the baggage as a slog.Group. +// Baggage properties that have no value are mapped to slog.Any with a nil +// value. +// +// Baggage mapped attributes take precedence over any preexisting attributes +// that a handler or logging record may already have. +// +// For example, "a=one,b=two;p1;p2=val2" would map to +// +// slog.String("otel-baggage/a", "one") +// slog.Group("otel-baggage/b", +// slog.String("value", "two"), +// slog.Group("properties", +// slog.Any("p1", nil), +// slog.String("p2", "val2"), +// ), +// ) +func WithOtelBaggage() options.OptionProcessor { + return func(options *options.Options) { + options.EntryAugmentors = append(options.EntryAugmentors, addBaggage) + } +} + +// MustParse wraps baggage.Parse to alleviate needless error checking +// when it's known, a priori, that an error can never happen. +func MustParse(bStr string) baggage.Baggage { + b, err := baggage.Parse(bStr) + if err != nil { + panic(err) + } + return b +} + +func addBaggage(ctx context.Context, e *logging.Entry, groups []string) { + b := baggage.FromContext(ctx) + + if len(b.Members()) == 0 { + return + } + + c := currentGroup(e, groups) + + for _, m := range b.Members() { + c.Fields[OtelBaggageKey+m.Key()] = baggageToGroup(m) + } +} + +func currentGroup(e *logging.Entry, groups []string) *spb.Struct { + payload := e.Payload.(*spb.Struct) + + for _, group := range groups { + v, ok := payload.Fields[group] + if !ok { + v = &spb.Value{ + Kind: &spb.Value_StructValue{ + StructValue: &spb.Struct{ + Fields: make(map[string]*spb.Value), + }, + }, + } + payload.Fields[group] = v + } + payload = v.GetStructValue() + } + + return payload +} + +func baggageToGroup(m baggage.Member) *spb.Value { + if len(m.Properties()) == 0 { + return &spb.Value{ + Kind: &spb.Value_StringValue{ + StringValue: m.Value(), + }, + } + } + + fields := make(map[string]*spb.Value) + v := &spb.Value{ + Kind: &spb.Value_StructValue{ + StructValue: &spb.Struct{ + Fields: fields, + }, + }, + } + + fields["value"] = &spb.Value{ + Kind: &spb.Value_StringValue{ + StringValue: m.Value(), + }, + } + + properties := make(map[string]*spb.Value) + + for _, p := range m.Properties() { + var value *spb.Value + val, has := p.Value() + if !has { + value = attr.NilValue + } else { + value = &spb.Value{ + Kind: &spb.Value_StringValue{ + StringValue: val, + }, + } + } + properties[p.Key()] = value + } + + fields["properties"] = &spb.Value{ + Kind: &spb.Value_StructValue{ + StructValue: &spb.Struct{ + Fields: properties, + }, + }, + } + + return v +} diff --git a/otel/baggage_test.go b/otel/baggage_test.go new file mode 100644 index 0000000..6a6327d --- /dev/null +++ b/otel/baggage_test.go @@ -0,0 +1,143 @@ +// 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" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel/baggage" + "google.golang.org/protobuf/proto" + spb "google.golang.org/protobuf/types/known/structpb" + + "m4o.io/gslog" + "m4o.io/gslog/internal/attr" + "m4o.io/gslog/otel" +) + +func TestWithOtelBaggage(t *testing.T) { + b := otel.MustParse("a=one,b=two;prop1;prop2=1") + for _, test := range []struct { + name string + groups []string + attrs []slog.Attr + baggage baggage.Baggage + want func() *spb.Struct + }{ + { + name: "a=one,b=two;prop1;prop2=1", + baggage: b, + want: func() *spb.Struct { + p := &spb.Struct{Fields: make(map[string]*spb.Value)} + attr.DecorateWith(p, slog.String("message", "how now brown cow")) + attr.DecorateWith(p, slog.String("otel-baggage/a", "one")) + attr.DecorateWith(p, slog.Group("otel-baggage/b", + slog.String("value", "two"), + slog.Group("properties", + slog.Any("prop1", nil), + slog.String("prop2", "1"), + ), + )) + + return p + }, + }, + { + name: "a=one,b=two;prop1;prop2=1 attr precedence", + attrs: []slog.Attr{slog.String("otel-baggage/a", "foo"), slog.String("otel-baggage/b", "bar")}, + baggage: b, + want: func() *spb.Struct { + p := &spb.Struct{Fields: make(map[string]*spb.Value)} + attr.DecorateWith(p, slog.String("message", "how now brown cow")) + attr.DecorateWith(p, slog.String("otel-baggage/a", "one")) + attr.DecorateWith(p, slog.Group("otel-baggage/b", + slog.String("value", "two"), + slog.Group("properties", + slog.Any("prop1", nil), + slog.String("prop2", "1"), + ), + )) + + return p + }, + }, + { + name: "a=one,b=two;prop1;prop2=1 within groups", + groups: []string{"g1", "g2"}, + baggage: b, + want: func() *spb.Struct { + p := &spb.Struct{Fields: make(map[string]*spb.Value)} + attr.DecorateWith(p, slog.String("message", "how now brown cow")) + attr.DecorateWith(p, slog.Group("g1", + slog.Group("g2", + slog.String("otel-baggage/a", "one"), + slog.Group("otel-baggage/b", + slog.String("value", "two"), + slog.Group("properties", + slog.Any("prop1", nil), + slog.String("prop2", "1"), + ), + ), + ), + )) + + return p + }, + }, + { + name: "no baggage", + baggage: baggage.Baggage{}, + want: func() *spb.Struct { + p := &spb.Struct{Fields: make(map[string]*spb.Value)} + attr.DecorateWith(p, slog.String("message", "how now brown cow")) + + return p + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + got := &Got{} + var h slog.Handler = gslog.NewGcpHandler(got, otel.WithOtelBaggage()) + + for _, group := range test.groups { + h = h.WithGroup(group) + } + if test.attrs != nil { + h = h.WithAttrs(test.attrs) + } + + ctx := context.Background() + if test.baggage.Len() != 0 { + ctx = baggage.ContextWithBaggage(ctx, test.baggage) + } + + l := slog.New(h) + l.Log(ctx, slog.LevelInfo, "how now brown cow") + + e := got.LogEntry + + expected := test.want() + + b, err := e.Payload.(*spb.Struct).MarshalJSON() + assert.NoError(t, err) + s := string(b) + _ = s + + assert.True(t, proto.Equal(expected, e.Payload.(proto.Message))) + }) + } +} diff --git a/otel/doc.go b/otel/doc.go new file mode 100644 index 0000000..2217c0c --- /dev/null +++ b/otel/doc.go @@ -0,0 +1,22 @@ +// 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 baggage and 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 diff --git a/otel/trace.go b/otel/trace.go new file mode 100644 index 0000000..217cd6d --- /dev/null +++ b/otel/trace.go @@ -0,0 +1,47 @@ +// 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 + +import ( + "context" + + "cloud.google.com/go/logging" + "go.opentelemetry.io/otel/trace" + + "m4o.io/gslog/internal/options" +) + +// WithOtelTracing returns an option that directs that the slog.Handler to +// include OpenTelemetry tracing. Tracing information is obtained from the +// trace.SpanContext stored in the context, if provided. +func WithOtelTracing() options.OptionProcessor { + return func(options *options.Options) { + options.EntryAugmentors = append(options.EntryAugmentors, addTrace) + } +} + +func addTrace(ctx context.Context, e *logging.Entry, _ []string) { + sc := trace.SpanContextFromContext(ctx) + + if sc.HasTraceID() { + e.Trace = sc.TraceID().String() + } + if sc.HasSpanID() { + e.SpanID = sc.SpanID().String() + } + if sc.IsSampled() { + e.TraceSampled = true + } +} diff --git a/otel/trace_test.go b/otel/trace_test.go new file mode 100644 index 0000000..8d2e95c --- /dev/null +++ b/otel/trace_test.go @@ -0,0 +1,73 @@ +// 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 (g *Got) Flush() error { + return nil +} + +func TestWithOtelTracing(t *testing.T) { + traceID, _ := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + + sCtx := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsSampled, + Remote: true, + }) + + ctx := context.Background() + ctx = trace.ContextWithRemoteSpanContext(ctx, sCtx) + + got := &Got{} + h := gslog.NewGcpHandler(got, otel.WithOtelTracing()) + l := slog.New(h) + + l.Log(ctx, slog.LevelInfo, "how now brown cow") + + e := got.LogEntry + + assert.Equal(t, traceID.String(), e.Trace) + assert.Equal(t, spanID.String(), e.SpanID) + assert.True(t, e.TraceSampled) +} diff --git a/severity.go b/severity.go new file mode 100644 index 0000000..54c97cd --- /dev/null +++ b/severity.go @@ -0,0 +1,32 @@ +// Copyright 2024 The original author or authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gslog + +import ( + "log/slog" +) + +const ( + // LevelNotice means normal but significant events, such as start up, + // shut down, or configuration. + LevelNotice = slog.Level(2) + // LevelCritical means events that cause more severe problems or brief + // outages. + LevelCritical = slog.Level(12) + // LevelAlert means a person must take an action immediately. + LevelAlert = slog.Level(16) + // LevelEmergency means one or more systems are unusable. + LevelEmergency = slog.Level(20) +)