From 49dc72d2c36d6647e8014d599a8a612404539cca Mon Sep 17 00:00:00 2001
From: "Alan D. Cabrera" <adc@toolazydogs.com>
Date: Mon, 15 Apr 2024 18:37:17 -0700
Subject: [PATCH 1/2] Initial commit

---
 .github/codecov.yml                |   25 +
 .github/dependabot.yml             |   18 +
 .github/workflows/ci.yml           |   65 ++
 .golangci.yaml                     |  327 ++++++++
 README.md                          |   81 +-
 attr.go                            |   44 ++
 doc.go                             |   16 +
 example_test.go                    |  331 ++++++++
 go.mod                             |   54 ++
 go.sum                             |  212 +++++
 gslog_suite_test.go                |   29 +
 handler.go                         |  290 +++++++
 handler_test.go                    | 1172 ++++++++++++++++++++++++++++
 internal/attr/attr.go              |  273 +++++++
 internal/attr/attr_test.go         |  315 ++++++++
 internal/level/level.go            |   38 +
 internal/level/level_suite_test.go |   29 +
 internal/level/level_test.go       |   41 +
 internal/options/option.go         |  120 +++
 k8s/podinfo.go                     |   83 ++
 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                          |   77 ++
 labels_test.go                     |  116 +++
 logger.go                          |   76 ++
 logger_test.go                     |   78 ++
 option.go                          |  103 +++
 option_test.go                     |  109 +++
 otel/baggage.go                    |  169 ++++
 otel/baggage_test.go               |  143 ++++
 otel/doc.go                        |   22 +
 otel/trace.go                      |   48 ++
 otel/trace_test.go                 |   73 ++
 severity.go                        |   32 +
 36 files changed, 4739 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 .golangci.yaml
 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..05ce798
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,65 @@
+name: Workflow for CI
+on: [ push, pull_request ]
+jobs:
+  run:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        go-version: [ '1.21', '1.22' ]
+    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: golangci-lint
+        uses: golangci/golangci-lint-action@v4
+
+      - 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/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 0000000..c63aac3
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,327 @@
+# Options for analysis running.
+run:
+  # Number of operating system threads (`GOMAXPROCS`) that can execute golangci-lint simultaneously.
+  # If it is explicitly set to 0 (i.e. not the default) then golangci-lint will automatically set the value to match Linux container CPU quota.
+  # Default: the number of logical CPUs in the machine
+  concurrency: 4
+  # Timeout for analysis, e.g. 30s, 5m.
+  # Default: 1m
+  timeout: 5m
+  # Exit code when at least one issue was found.
+  # Default: 1
+  issues-exit-code: 2
+  # Include test files or not.
+  # Default: true
+  tests: false
+  # List of build tags, all linters use it.
+  # Default: []
+  # build-tags:
+  #  - mytag
+  # If set, we pass it to "go list -mod={option}". From "go help modules":
+  # If invoked with -mod=readonly, the go command is disallowed from the implicit
+  # automatic updating of go.mod described above. Instead, it fails when any changes
+  # to go.mod are needed. This setting is most useful to check that go.mod does
+  # not need updates, such as in a continuous integration and testing system.
+  # If invoked with -mod=vendor, the go command assumes that the vendor
+  # directory holds the correct copies of dependencies and ignores
+  # the dependency descriptions in go.mod.
+  #
+  # Allowed values: readonly|vendor|mod
+  # Default: ""
+  modules-download-mode: readonly
+  # Allow multiple parallel golangci-lint instances running.
+  # If false, golangci-lint acquires file lock on start.
+  # Default: false
+  allow-parallel-runners: true
+  # Allow multiple golangci-lint instances running, but serialize them around a lock.
+  # If false, golangci-lint exits with an error if it fails to acquire file lock on start.
+  # Default: false
+  allow-serial-runners: true
+  # Define the Go version limit.
+  # Mainly related to generics support since go1.18.
+  # Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.17
+  go: '1.21'
+
+linters-settings:
+  varnamelen:
+    # The longest distance, in source lines, that is being considered a "small scope." (defaults to 5)
+    # Variables used in at most this many lines will be ignored.
+    max-distance: 5
+    # The minimum length of a variable's name that is considered "long." (defaults to 3)
+    # Variable names that are at least this long will be ignored.
+    min-name-length: 3
+    # Check method receivers. (defaults to false)
+    check-receiver: false
+    # Check named return values. (defaults to false)
+    check-return: false
+    # Check type parameters. (defaults to false)
+    check-type-param: false
+    # Ignore "ok" variables that hold the bool return value of a type assertion. (defaults to false)
+    ignore-type-assert-ok: false
+    # Ignore "ok" variables that hold the bool return value of a map index. (defaults to false)
+    ignore-map-index-ok: false
+    # Ignore "ok" variables that hold the bool return value of a channel receive. (defaults to false)
+    ignore-chan-recv-ok: false
+    # Optional list of variable names that should be ignored completely. (defaults to empty list)
+    ignore-names:
+      - err
+    # Optional list of variable declarations that should be ignored completely. (defaults to empty list)
+    # Entries must be in one of the following forms (see below for examples):
+    # - for variables, parameters, named return values, method receivers, or type parameters:
+    #   <name> <type>  (<type> can also be a pointer/slice/map/chan/...)
+    # - for constants: const <name>
+    ignore-decls:
+      - a any
+      - c echo.Context
+      - const C
+      - e error
+      - i int
+      - m map[string]int
+      - T any
+      - t testing.T
+      - t time.Time
+      - v slog.Value
+
+  exhaustive:
+    # Presence of "default" case in switch statements satisfies exhaustiveness,
+    # even if all enum members are not listed.
+    # Default: false
+    default-signifies-exhaustive: true
+
+output:
+  # The formats used to render issues.
+  # Format: `colored-line-number`, `line-number`, `json`, `colored-tab`, `tab`, `checkstyle`, `code-climate`, `junit-xml`, `github-actions`, `teamcity`
+  # Output path can be either `stdout`, `stderr` or path to the file to write to.
+  #
+  # For the CLI flag (`--out-format`), multiple formats can be specified by separating them by comma.
+  # The output can be specified for each of them by separating format name and path by colon symbol.
+  # Example: "--out-format=checkstyle:report.xml,json:stdout,colored-line-number"
+  # The CLI flag (`--out-format`) override the configuration file.
+  #
+  # Default:
+  #   formats:
+  #     - format: colored-line-number
+  #       path: stdout
+  formats:
+    #    - format: json
+    #      path: stderr
+    #    - format: checkstyle
+    #      path: report.xml
+    - format: colored-line-number
+  # Print lines of code with issue.
+  # Default: true
+  print-issued-lines: false
+  # Print linter name in the end of issue text.
+  # Default: true
+  print-linter-name: false
+  # Make issues output unique by line.
+  # Default: true
+  uniq-by-line: false
+  # Add a prefix to the output file references.
+  # Default: ""
+  path-prefix: ""
+  # Sort results by the order defined in `sort-order`.
+  # Default: false
+  sort-results: true
+  # Order to use when sorting results.
+  # Require `sort-results` to `true`.
+  # Possible values: `file`, `linter`, and `severity`.
+  #
+  # If the severity values are inside the following list, they are ordered in this order:
+  #   1. error
+  #   2. warning
+  #   3. high
+  #   4. medium
+  #   5. low
+  # Either they are sorted alphabetically.
+  #
+  # Default: ["file"]
+  sort-order:
+    - linter
+    - severity
+    - file # filepath, line, and column.
+  # Show statistics per linter.
+  # Default: false
+  show-stats: true
+
+linters:
+  enable-all: true
+  # Disable specific linter
+  # https://golangci-lint.run/usage/linters/#disabled-by-default
+  disable:
+    - copyloopvar
+    - depguard
+    - gci
+    - intrange
+    - deadcode # Deprecated
+    - exhaustivestruct # Deprecated
+    - golint # Deprecated
+    - ifshort # Deprecated
+    - interfacer # Deprecated
+    - maligned # Deprecated
+    - nosnakecase # Deprecated
+    - scopelint # Deprecated
+    - structcheck # Deprecated
+    - varcheck # Deprecated
+  # Enable only fast linters from enabled linters set (first run won't be fast)
+  # Default: false
+  # fast: true
+
+issues:
+  # List of regexps of issue texts to exclude.
+  #
+  # But independently of this option we use default exclude patterns,
+  # it can be disabled by `exclude-use-default: false`.
+  # To list all excluded by default patterns execute `golangci-lint run --help`
+  #
+  # Default: https://golangci-lint.run/usage/false-positives/#default-exclusions
+  # exclude:
+  #   - abcdef
+  # Excluding configuration per-path, per-linter, per-text and per-source
+  exclude-rules:
+    # Exclude some linters from running on tests files.
+    - path: _test\.go
+      linters:
+        - gocyclo
+        - errcheck
+        - dupl
+        - gosec
+    # Run some linter only for test files by excluding its issues for everything else.
+    - path-except: _test\.go
+      linters:
+        - forbidigo
+    # Exclude known linters from partially hard-vendored code,
+    # which is impossible to exclude via `nolint` comments.
+    # `/` will be replaced by current OS file path separator to properly work on Windows.
+    - path: internal/hmac/
+      text: "weak cryptographic primitive"
+      linters:
+        - gosec
+    # Exclude some `staticcheck` messages.
+    - linters:
+        - staticcheck
+      text: "SA9003:"
+    # Exclude `lll` issues for long lines with `go:generate`.
+    - linters:
+        - lll
+      source: "^//go:generate "
+  # Independently of option `exclude` we use default exclude patterns,
+  # it can be disabled by this option.
+  # To list all excluded by default patterns execute `golangci-lint run --help`.
+  # Default: true
+  exclude-use-default: false
+  # If set to true, `exclude` and `exclude-rules` regular expressions become case-sensitive.
+  # Default: false
+  exclude-case-sensitive: false
+  # Which dirs to exclude: issues from them won't be reported.
+  # Can use regexp here: `generated.*`, regexp is applied on full path,
+  # including the path prefix if one is set.
+  # Default dirs are skipped independently of this option's value (see exclude-dirs-use-default).
+  # "/" will be replaced by current OS file path separator to properly work on Windows.
+  # Default: []
+  exclude-dirs:
+    - src/external_libs
+    - autogenerated_by_my_lib
+  # Enables exclude of directories:
+  # - vendor$, third_party$, testdata$, examples$, Godeps$, builtin$
+  # Default: true
+  exclude-dirs-use-default: false
+  # Which files to exclude: they will be analyzed, but issues from them won't be reported.
+  # There is no need to include all autogenerated files,
+  # we confidently recognize autogenerated files.
+  # If it's not, please let us know.
+  # "/" will be replaced by current OS file path separator to properly work on Windows.
+  # Default: []
+  exclude-files:
+    - ".*\\.my\\.go$"
+    - lib/bad.go
+  # To follow strictly the Go generated file convention.
+  #
+  # If set to true, source files that have lines matching only the following regular expression will be excluded:
+  #   `^// Code generated .* DO NOT EDIT\.$`
+  # This line must appear before the first non-comment, non-blank text in the file.
+  # https://go.dev/s/generatedcode
+  #
+  # By default, a lax pattern is applied:
+  # sources are excluded if they contain lines `autogenerated file`, `code generated`, `do not edit`, etc.
+  # Default: false
+  exclude-generated-strict: true
+  # The list of ids of default excludes to include or disable.
+  # https://golangci-lint.run/usage/false-positives/#default-exclusions
+  # Default: []
+  include:
+    - EXC0001
+    - EXC0002
+    - EXC0003
+    - EXC0004
+    - EXC0005
+    - EXC0006
+    - EXC0007
+    - EXC0008
+    - EXC0009
+    - EXC0010
+    - EXC0011
+    - EXC0012
+    - EXC0013
+    - EXC0014
+    - EXC0015
+  # Maximum issues count per one linter.
+  # Set to 0 to disable.
+  # Default: 50
+  max-issues-per-linter: 0
+  # Maximum count of issues with the same text.
+  # Set to 0 to disable.
+  # Default: 3
+  max-same-issues: 0
+  # Show only new issues: if there are unstaged changes or untracked files,
+  # only those changes are analyzed, else only changes in HEAD~ are analyzed.
+  # It's a super-useful option for integration of golangci-lint into existing large codebase.
+  # It's not practical to fix all existing issues at the moment of integration:
+  # much better don't allow issues in new code.
+  #
+  # Default: false
+  # new: true
+  # Show only new issues created after git revision `REV`.
+  # Default: ""
+  # new-from-rev: HEAD
+  # Show only new issues created in git patch with set file path.
+  # Default: ""
+  # new-from-patch: path/to/patch/file
+  # Fix found issues (if it's supported by the linter).
+  # Default: false
+  # fix: true
+  # Show issues in any part of update files (requires new-from-rev or new-from-patch).
+  # Default: false
+  whole-files: true
+
+severity:
+  # Set the default severity for issues.
+  #
+  # If severity rules are defined and the issues do not match or no severity is provided to the rule
+  # this will be the default severity applied.
+  # Severities should match the supported severity names of the selected out format.
+  # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity
+  # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#SeverityLevel
+  # - GitHub: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message
+  # - TeamCity: https://www.jetbrains.com/help/teamcity/service-messages.html#Inspection+Instance
+  #
+  # `@linter` can be used as severity value to keep the severity from linters (e.g. revive, gosec, ...)
+  #
+  # Default: ""
+  default-severity: error
+  # If set to true `severity-rules` regular expressions become case-sensitive.
+  # Default: false
+  case-sensitive: true
+  # When a list of severity rules are provided, severity information will be added to lint issues.
+  # Severity rules have the same filtering capability as exclude rules
+  # except you are allowed to specify one matcher per severity rule.
+  #
+  # `@linter` can be used as severity value to keep the severity from linters (e.g. revive, gosec, ...)
+  #
+  # Only affects out formats that support setting severity information.
+  #
+  # Default: []
+  rules:
+    - linters:
+        - dupl
+      severity: info
diff --git a/README.md b/README.md
index 64f752c..5773c8d 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.Mapper` | 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..fd62b02
--- /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 (
+	"m4o.io/gslog/internal/attr"
+)
+
+// 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 attr.Mapper
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..8006bff
--- /dev/null
+++ b/example_test.go
@@ -0,0 +1,331 @@
+// 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("<secret>")
+	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 "<secret>"
+}
+
+func (p Password) MarshalJSON() ([]byte, error) {
+	return []byte(strconv.Quote("<secret>")), nil
+}
+
+func (p Password) LogValue() slog.Value {
+	return pwObfuscated
+}
+
+type User struct {
+	ID        string   `json:"id"`
+	FirstName string   `json:"first_name"`
+	LastName  string   `json:"last_name"`
+	Email     string   `json:"email"`
+	Password  Password `json:"password"`
+	Age       uint8    `json:"age"`
+	Height    float32  `json:"height"`
+	Engineer  bool     `json:"engineer"`
+	Manager   *Manager `json:"manager"`
+}
+
+// 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":"259"}
+}
+
+// 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..cea7b03
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,54 @@
+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/pkg/errors v0.9.1
+	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..5fbfe71
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,212 @@
+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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+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..7e80c88
--- /dev/null
+++ b/handler.go
@@ -0,0 +1,290 @@
+// 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"
+	"github.com/pkg/errors"
+	"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     attr.Mapper
+
+	payload *spb.Struct
+	groups  []string
+}
+
+var _ slog.Handler = (*GcpHandler)(nil)
+
+// 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, opts *options.Options) *GcpHandler {
+	handler := &GcpHandler{
+		log:   logger,
+		level: opts.Level,
+
+		addSource:       opts.AddSource,
+		entryAugmentors: opts.EntryAugmentors,
+		replaceAttr:     attr.WrapAttrMapper(opts.ReplaceAttr),
+
+		payload: &spb.Struct{Fields: make(map[string]*spb.Value)},
+		groups:  nil,
+	}
+
+	return handler
+}
+
+// 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, ok := proto.Clone(h.payload).(*spb.Struct)
+	if !ok {
+		panic("proto.Clone failed")
+	}
+
+	if payload2.Fields == nil {
+		payload2.Fields = make(map[string]*spb.Value)
+	}
+
+	setAndClean(h.groups, payload2, func(_ []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
+		})
+	})
+
+	a := slog.String(MessageKey, record.Message)
+	if h.replaceAttr != nil {
+		a = h.replaceAttr(nil, a)
+	}
+
+	attr.DecorateWith(payload2, a)
+
+	var entry logging.Entry
+
+	entry.Payload = payload2
+	entry.Timestamp = record.Time.UTC()
+	entry.Severity = level.ToSeverity(record.Level)
+	entry.Labels = ExtractLabels(ctx)
+
+	if h.addSource {
+		addSourceLocation(&entry, &record)
+	}
+
+	for _, b := range h.entryAugmentors {
+		b(ctx, &entry, h.groups)
+	}
+
+	if entry.Severity >= logging.Critical {
+		err := h.log.LogSync(ctx, entry)
+		if err != nil {
+			_, _ = fmt.Fprintf(os.Stderr, "error logging: %s\n%s", record.Message, err)
+		}
+	} else {
+		h.log.Log(entry)
+	}
+
+	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 {
+	handler2 := h.clone()
+
+	current := fromPath(handler2.payload, handler2.groups)
+
+	for _, a := range attrs {
+		if h.replaceAttr != nil {
+			a = h.replaceAttr(h.groups, a)
+		}
+
+		attr.DecorateWith(current, a)
+	}
+
+	return handler2
+}
+
+// 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
+	}
+
+	handler2 := h.clone()
+
+	payload2, ok := proto.Clone(h.payload).(*spb.Struct)
+	if !ok {
+		panic("expected *spb.Struct")
+	}
+
+	handler2.payload = payload2
+
+	current := fromPath(handler2.payload, handler2.groups)
+
+	current.Fields[name] = &spb.Value{
+		Kind: &spb.Value_StructValue{
+			StructValue: &spb.Struct{
+				Fields: make(map[string]*spb.Value),
+			},
+		},
+	}
+
+	handler2.groups = h.groups
+	handler2.groups = append(handler2.groups, name)
+
+	return handler2
+}
+
+// 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.
+func (h *GcpHandler) Flush() error {
+	if err := h.log.Flush(); err != nil {
+		return errors.Wrap(err, "failed to flush handler")
+	}
+
+	return nil
+}
+
+func (h *GcpHandler) clone() *GcpHandler {
+	payload2, ok := proto.Clone(h.payload).(*spb.Struct)
+	if !ok {
+		panic("expected *spb.Struct")
+	}
+
+	return &GcpHandler{
+		log:   h.log,
+		level: h.level,
+
+		addSource:       h.addSource,
+		entryAugmentors: h.entryAugmentors,
+		replaceAttr:     h.replaceAttr,
+
+		payload: payload2,
+		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(payload *spb.Struct, path []string) *spb.Struct {
+	for _, k := range path {
+		payload = payload.GetFields()[k].GetStructValue()
+	}
+
+	if payload.Fields == nil {
+		payload.Fields = make(map[string]*spb.Value)
+	}
+
+	return payload
+}
+
+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
+	}
+
+	group := groups[0]
+
+	s := payload.GetFields()[group].GetStructValue()
+	setAndClean(groups[1:], s, decorate)
+
+	if len(s.GetFields()) == 0 {
+		delete(payload.GetFields(), group)
+	}
+}
diff --git a/handler_test.go b/handler_test.go
new file mode 100644
index 0000000..f5ec45d
--- /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 Mapper 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{{Key: "v", Value: 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...)
+
+			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{}
+	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{}
+	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.Mapper
+// 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..f2b3709
--- /dev/null
+++ b/internal/attr/attr.go
@@ -0,0 +1,273 @@
+// 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"
+
+	"github.com/pkg/errors"
+	spb "google.golang.org/protobuf/types/known/structpb"
+)
+
+//nolint:gochecknoglobals
+var nilValue = &spb.Value{Kind: &spb.Value_NullValue{NullValue: spb.NullValue_NULL_VALUE}}
+
+// Mapper functions are called to rewrite each non-group attribute before it is logged.
+type Mapper func(groups []string, attr slog.Attr) slog.Attr
+
+// WrapAttrMapper will wrap an mapper with empty group checks to ensure they
+// are properly elided.
+func WrapAttrMapper(mapper Mapper) Mapper {
+	if mapper == nil {
+		return nil
+	}
+
+	var wrapped Mapper
+
+	wrapped = func(groups []string, attr slog.Attr) slog.Attr {
+		if attr.Value.Kind() == slog.KindGroup {
+			var attrs []any
+
+			for _, ga := range attr.Value.Group() {
+				mapped := wrapped(append(groups, attr.Key), ga)
+
+				// elide empty attributes
+				if mapped.Key == "" && mapped.Value.Any() == nil {
+					continue
+				}
+
+				attrs = append(attrs, mapped)
+			}
+
+			if len(attrs) == 0 {
+				//nolint:exhaustruct
+				return slog.Attr{}
+			}
+
+			return slog.Group(attr.Key, attrs...)
+		}
+
+		return mapper(groups, attr)
+	}
+
+	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(payload *spb.Struct, attr slog.Attr) {
+	rv := attr.Value.Resolve()
+	if attr.Key == "" && rv.Any() == nil {
+		return
+	}
+
+	val, ok := ValToStruct(rv)
+	if !ok {
+		return
+	}
+
+	if attr.Key == "" && attr.Value.Kind() == slog.KindGroup {
+		for k, v := range val.GetStructValue().GetFields() {
+			payload.Fields[k] = v
+		}
+	} else {
+		payload.Fields[attr.Key] = val
+	}
+}
+
+// ValToStruct creates the spb.Value equivalent of the supplied slog.Value value.
+//
+//nolint:cyclop
+func ValToStruct(v slog.Value) (*spb.Value, 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:
+		return NewAny(v.Any())
+	default:
+		return nil, false
+	}
+}
+
+// NewNilValue is the spb.Value equivalent of nil.
+func NewNilValue() *spb.Value {
+	return nilValue
+}
+
+// NewStringValue creates the spb.Value equivalent of the supplied string.
+func NewStringValue(str string) *spb.Value {
+	return &spb.Value{Kind: &spb.Value_StringValue{StringValue: str}}
+}
+
+// NewNumberValue creates the spb.Value equivalent of the supplied float64.
+func NewNumberValue(val float64) *spb.Value {
+	return &spb.Value{Kind: &spb.Value_NumberValue{NumberValue: val}}
+}
+
+// NewBoolValue creates the spb.Value equivalent of the supplied bool.
+func NewBoolValue(b bool) *spb.Value {
+	return &spb.Value{Kind: &spb.Value_BoolValue{BoolValue: b}}
+}
+
+// NewGroupValue creates the spb.Value equivalent of the supplied slog.Attr array.
+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}}
+}
+
+// NewAny creates the spb.Value equivalent of the supplied any instance.
+func NewAny(a any) (*spb.Value, bool) {
+	// 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)
+}
+
+// NewTimeValue creates the spb.Value equivalent of the supplied time.Time instance.
+func NewTimeValue(t time.Time) *spb.Value {
+	return &spb.Value{Kind: &spb.Value_StringValue{StringValue: TimeToRFC3339InMs(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) (*spb.Value, bool) {
+	if a == nil {
+		return nilValue, true
+	}
+
+	a, err := ToJSON(a)
+	if err != nil {
+		return nil, false
+	}
+
+	value, _ := spb.NewValue(a)
+
+	return value, true
+}
+
+// ToJSON converts an instance of any to a JSON object map[string]interface{}.
+// An error is returned if the instance cannot be encoded into JSON.
+func ToJSON(a any) (any, error) {
+	var buf bytes.Buffer
+
+	enc := json.NewEncoder(&buf)
+
+	if err := enc.Encode(a); err != nil {
+		return nil, errors.Wrap(err, "unable to encode attr")
+	}
+
+	var result any
+	_ = json.Unmarshal(buf.Bytes(), &result)
+
+	return result, nil
+}
+
+//nolint:gochecknoglobals
+var timePool = sync.Pool{
+	New: func() any {
+		const prefixLen = len(time.RFC3339Nano) + 1
+		b := make([]byte, 0, prefixLen)
+
+		return &b
+	},
+}
+
+// TimeToRFC3339InMs formats an instance of time.Time to an RFC3339 defined
+// layout in milliseconds in a performant manner.
+func TimeToRFC3339InMs(t time.Time) string {
+	ptr, ok := timePool.Get().(*[]byte)
+	if !ok {
+		panic("expected *[]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.
+	const prefixLen = len("2006-01-02T15:04:05.000")
+
+	// Unfortunately, that format trims trailing 0s, so add 1/10 millisecond
+	// to guarantee that there are exactly 4 digits after the period.
+	const rounding = time.Millisecond / 10
+
+	n := len(buf)
+
+	t = t.Truncate(time.Millisecond).Add(rounding)
+
+	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..3dc675b
--- /dev/null
+++ b/internal/attr/attr_test.go
@@ -0,0 +1,315 @@
+// Copyright 2024 The original author or authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package attr_test
+
+import (
+	"errors"
+	"fmt"
+	"log/slog"
+	"math"
+	"reflect"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"google.golang.org/protobuf/types/known/structpb"
+
+	"m4o.io/gslog/internal/attr"
+)
+
+type Circular struct {
+	Self *Circular `json:"self"`
+}
+
+type Manager struct{}
+
+type Password string
+
+func (p Password) MarshalJSON() ([]byte, error) {
+	return []byte(strconv.Quote("<secret>")), nil
+}
+
+func (p Password) LogValue() slog.Value {
+	return pwObfuscated
+}
+
+type User struct {
+	ID        string   `json:"id"`
+	FirstName string   `json:"first_name"`
+	LastName  string   `json:"last_name"`
+	Email     string   `json:"email"`
+	Password  Password `json:"password"`
+	Age       uint8    `json:"age"`
+	Height    float32  `json:"height"`
+	Engineer  bool     `json:"engineer"`
+	Manager   *Manager `json:"manager"`
+}
+
+type Chimera struct {
+	Name string `json:"name"`
+}
+
+func (u *Chimera) MarshalJSON() ([]byte, error) {
+	return []byte(fmt.Sprintf(`{"name":"%s"}`, u.Name)), nil
+}
+
+// Error should never be called since
+func (u *Chimera) Error() string {
+	panic("ouch")
+}
+
+var (
+	pw           = Password("pass-12334")
+	pwObfuscated = slog.StringValue("<secret>")
+	u            = &User{
+		ID:        "user-12234",
+		FirstName: "Jan",
+		LastName:  "Doe",
+		Email:     "jan@example.com",
+		Password:  pw,
+		Age:       32,
+		Height:    5.91,
+		Engineer:  true,
+	}
+
+	uJson = map[string]interface{}{
+		"id":         "user-12234",
+		"first_name": "Jan",
+		"last_name":  "Doe",
+		"email":      "jan@example.com",
+		"password":   "<secret>",
+		"age":        float64(32),
+		"height":     5.91,
+		"engineer":   true,
+		"manager":    nil,
+	}
+
+	uStruct *structpb.Value
+
+	uGroup []slog.Attr
+
+	circular *Circular
+
+	chimera = &Chimera{Name: "Pookie Bear"}
+	cStruct = &structpb.Value{
+		Kind: &structpb.Value_StructValue{
+			StructValue: &structpb.Struct{
+				Fields: map[string]*structpb.Value{
+					"name": {
+						Kind: &structpb.Value_StringValue{StringValue: "Pookie Bear"},
+					},
+				},
+			},
+		},
+	}
+)
+
+func init() {
+	circular = &Circular{}
+	circular.Self = circular
+
+	fields := make(map[string]*structpb.Value)
+	fields["id"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "user-12234"}}
+	fields["first_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Jan"}}
+	fields["last_name"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "Doe"}}
+	fields["email"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "jan@example.com"}}
+	fields["password"] = &structpb.Value{Kind: &structpb.Value_StringValue{StringValue: "<secret>"}}
+	fields["age"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: float64(32)}}
+	fields["height"] = &structpb.Value{Kind: &structpb.Value_NumberValue{NumberValue: 5.91}}
+	fields["engineer"] = &structpb.Value{Kind: &structpb.Value_BoolValue{BoolValue: true}}
+	fields["manager"] = attr.NewNilValue()
+	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.NewNilValue(), 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.NewNilValue(), true},
+		"string":                 {slog.StringValue("how now brown cow"), attr.NewStringValue("how now brown cow"), true},
+		"int64":                  {slog.Int64Value(math.MaxInt64), attr.NewNumberValue(float64(math.MaxInt64)), true},
+		"uint64":                 {slog.Uint64Value(math.MaxUint64), attr.NewNumberValue(float64(math.MaxUint64)), true},
+		"float64":                {slog.Float64Value(math.MaxFloat64), attr.NewNumberValue(math.MaxFloat64), true},
+		"bool true":              {slog.BoolValue(true), attr.NewBoolValue(true), true},
+		"bool false":             {slog.BoolValue(false), attr.NewBoolValue(false), true},
+		"duration":               {slog.DurationValue(time.Minute * 5), attr.NewNumberValue(float64(time.Minute * 5)), true},
+		"time":                   {slog.TimeValue(now), attr.NewTimeValue(now), true},
+		"group":                  {slog.GroupValue(uGroup...), uStruct, true},
+		"group empty":            {slog.GroupValue(), nil, false},
+		"any LogValuer":          {slog.AnyValue(pw), nil, false}, // this should have been transformed earlier via Resolve()
+		"any resolved LogValuer": {slog.AnyValue(pw).Resolve(), attr.NewStringValue("<secret>"), true},
+		"any JSON":               {slog.AnyValue(u), uStruct, true},
+		"any json.Marshaler":     {slog.AnyValue(chimera), cStruct, true},
+		"any error":              {slog.AnyValue(errors.New("ouch")), attr.NewStringValue("ouch"), true},
+		"error":                  {slog.AnyValue(circular), nil, false},
+	}
+
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			value, ok := attr.ValToStruct(tc.attr)
+			assert.Equal(t, tc.ok, ok)
+			if tc.ok {
+				assert.Equal(t, tc.value, value)
+			}
+		})
+	}
+}
+
+type mapper func(a slog.Attr) slog.Attr
+
+func removeMapper(_ slog.Attr) slog.Attr {
+	return slog.Attr{}
+}
+
+func genReplace(r slog.Attr, groups ...string) attr.Mapper {
+	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) attr.Mapper {
+	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   attr.Mapper
+		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.TimeToRFC3339InMs(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.TimeToRFC3339InMs(tm)
+	}
+}
diff --git a/internal/level/level.go b/internal/level/level.go
new file mode 100644
index 0000000..ef3a93e
--- /dev/null
+++ b/internal/level/level.go
@@ -0,0 +1,38 @@
+// 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 contains code that maps slog.Level levels to logging.Severity.
+package level
+
+import (
+	"log/slog"
+
+	"cloud.google.com/go/logging"
+)
+
+const (
+	severityIntercept = 8
+	severitySlope     = 4
+	severityIncrement = 100
+)
+
+// ToSeverity converts slog.Level logging levels to logging.Severity.
+func ToSeverity(level slog.Level) logging.Severity {
+	severity := logging.Severity((int(level) + severityIntercept) / severitySlope * severityIncrement)
+	if slog.LevelInfo < level {
+		return severity + severityIncrement
+	}
+
+	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..cc21cd6
--- /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.ToSeverity(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..5a58bb2
--- /dev/null
+++ b/internal/options/option.go
@@ -0,0 +1,120 @@
+// 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"
+)
+
+const (
+	levelUnknown = slog.Level(math.MaxInt)
+)
+
+// EntryAugmentor augments an instance of logging.Entry.  The current context
+// and group path is provided, in case they are needed by the augmentor.
+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
+}
+
+// OptionProcessor interacts with the supplied Options instance.
+type OptionProcessor func(o *Options)
+
+// ApplyOptions applies the option processors, OptionProcessor, to
+// an instance of Options which it returns.
+func ApplyOptions(options ...OptionProcessor) *Options {
+	opts := &Options{
+		EnvVarLogLevel:   levelUnknown,
+		ExplicitLogLevel: levelUnknown,
+		DefaultLogLevel:  levelUnknown,
+
+		EntryAugmentors: nil,
+		AddSource:       false,
+		Level:           slog.LevelInfo,
+		ReplaceAttr:     nil,
+	}
+	for _, opt := range options {
+		opt(opts)
+	}
+
+	opts.Level = opts.DefaultLogLevel
+
+	if opts.EnvVarLogLevel != levelUnknown {
+		opts.Level = opts.EnvVarLogLevel
+	}
+
+	if opts.ExplicitLogLevel != levelUnknown {
+		opts.Level = opts.ExplicitLogLevel
+	}
+
+	if opts.Level == levelUnknown {
+		opts.Level = slog.LevelInfo
+	}
+
+	return opts
+}
diff --git a/k8s/podinfo.go b/k8s/podinfo.go
new file mode 100644
index 0000000..eb4b054
--- /dev/null
+++ b/k8s/podinfo.go
@@ -0,0 +1,83 @@
+// 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")
+
+	props, 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(_ context.Context, entry *logging.Entry, _ []string) {
+		if entry.Labels == nil {
+			entry.Labels = make(map[string]string)
+		}
+
+		for key, val := range props.Map() {
+			if val[0] == '"' {
+				val = val[1 : len(val)-1]
+			}
+
+			key = PodPrefix + key
+			entry.Labels[key] = val
+		}
+	}
+}
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..fec3d8f
--- /dev/null
+++ b/labels.go
@@ -0,0 +1,77 @@
+// 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..19745eb
--- /dev/null
+++ b/logger.go
@@ -0,0 +1,76 @@
+// 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
+}
+
+// Log wraps the asynchronous buffered logging of records to
+// Google Cloud Logging.
+type Log interface {
+	// Log buffers the Entry for output to the logging service. It never blocks.
+	Log(e logging.Entry)
+}
+
+// LogSync wraps the synchronous logging of records to
+// Google Cloud Logging.
+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)
+
+// Log implements Log.Log.
+func (fn LoggerFunc) Log(e logging.Entry) {
+	fn(e)
+}
+
+// LogSync implements LogSync.LogSync.
+func (fn LoggerFunc) LogSync(_ context.Context, e logging.Entry) error {
+	fn(e)
+
+	return nil
+}
+
+// Flush implements Logger.Flush.
+func (fn LoggerFunc) Flush() error {
+	return nil
+}
diff --git a/logger_test.go b/logger_test.go
new file mode 100644
index 0000000..64e5081
--- /dev/null
+++ b/logger_test.go
@@ -0,0 +1,78 @@
+// 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"
+)
+
+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
+}
+
+// Discard can be used as a do-nothing Logger that can be used for testing and
+// to stub out Google Cloud Logging when benchmarking.
+var Discard gslog.Logger = discard{}
+
+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 := Discard
+	l.Log(logging.Entry{})
+}
+
+func TestDiscard_LogSync(t *testing.T) {
+	l := 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..47cd43b
--- /dev/null
+++ b/option.go
@@ -0,0 +1,103 @@
+// 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
+	}
+
+	str, ok := os.LookupEnv(key)
+	if !ok {
+		return func(_ *options.Options) {}
+	}
+
+	lvl, err := strconv.Atoi(str)
+	if err == nil {
+		envVarLogLevel = slog.Level(lvl)
+
+		return setLogLevel
+	}
+
+	switch str {
+	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..f4c631c
--- /dev/null
+++ b/otel/baggage.go
@@ -0,0 +1,169 @@
+// 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 {
+	bag, err := baggage.Parse(bStr)
+	if err != nil {
+		panic(err)
+	}
+
+	return bag
+}
+
+func addBaggage(ctx context.Context, entry *logging.Entry, groups []string) {
+	bag := baggage.FromContext(ctx)
+
+	if len(bag.Members()) == 0 {
+		return
+	}
+
+	c := currentGroup(entry, groups)
+
+	for _, m := range bag.Members() {
+		c.Fields[OtelBaggageKey+m.Key()] = baggageToGroup(m)
+	}
+}
+
+func currentGroup(entry *logging.Entry, groups []string) *spb.Struct {
+	payload, ok := entry.Payload.(*spb.Struct)
+	if !ok {
+		panic("expected *spb.Struct")
+	}
+
+	for _, group := range groups {
+		value, ok := payload.GetFields()[group]
+		if !ok {
+			value = &spb.Value{
+				Kind: &spb.Value_StructValue{
+					StructValue: &spb.Struct{
+						Fields: make(map[string]*spb.Value),
+					},
+				},
+			}
+
+			payload.Fields[group] = value
+		}
+
+		payload = value.GetStructValue()
+	}
+
+	return payload
+}
+
+func baggageToGroup(member baggage.Member) *spb.Value {
+	if len(member.Properties()) == 0 {
+		return &spb.Value{
+			Kind: &spb.Value_StringValue{
+				StringValue: member.Value(),
+			},
+		}
+	}
+
+	fields := make(map[string]*spb.Value)
+	group := &spb.Value{
+		Kind: &spb.Value_StructValue{
+			StructValue: &spb.Struct{
+				Fields: fields,
+			},
+		},
+	}
+
+	fields["value"] = &spb.Value{
+		Kind: &spb.Value_StringValue{
+			StringValue: member.Value(),
+		},
+	}
+
+	properties := make(map[string]*spb.Value)
+
+	for _, prop := range member.Properties() {
+		var value *spb.Value
+
+		val, has := prop.Value()
+		if !has {
+			value = attr.NewNilValue()
+		} else {
+			value = &spb.Value{
+				Kind: &spb.Value_StringValue{
+					StringValue: val,
+				},
+			}
+		}
+
+		properties[prop.Key()] = value
+	}
+
+	fields["properties"] = &spb.Value{
+		Kind: &spb.Value_StructValue{
+			StructValue: &spb.Struct{
+				Fields: properties,
+			},
+		},
+	}
+
+	return group
+}
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..b163e74
--- /dev/null
+++ b/otel/trace.go
@@ -0,0 +1,48 @@
+// 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, entry *logging.Entry, _ []string) {
+	spanContext := trace.SpanContextFromContext(ctx)
+
+	if spanContext.HasTraceID() {
+		entry.Trace = spanContext.TraceID().String()
+	}
+
+	if spanContext.HasSpanID() {
+		entry.SpanID = spanContext.SpanID().String()
+	}
+
+	if spanContext.IsSampled() {
+		entry.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)
+)

From 440e8d88b6c0e2dd111d2e8a322a047b19150d4d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 16 Apr 2024 06:18:40 +0000
Subject: [PATCH 2/2] Bump the github-actions group with 2 updates

Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `actions/checkout` from 4.1.1 to 4.1.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/b4ffde65f46336ab88eb53be808477a3936bae11...9bb56186c3b09b4f86b1c65136769dd318469633)

Updates `codecov/codecov-action` from 4.1.0 to 4.3.0
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/54bcd8715eee62d40e33596ef5e8f0f48dbbccab...84508663e988701840491b86de86b666e8a86bed)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
---
 .github/workflows/ci.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 05ce798..66ac907 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,7 @@ jobs:
         go-version: [ '1.21', '1.22' ]
     steps:
       - name: Checkout
-        uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
+        uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
         with:
           fetch-depth: 0
 
@@ -56,7 +56,7 @@ jobs:
         shell: bash
 
       - name: Upload coverage to Codecov
-        uses: codecov/codecov-action@54bcd8715eee62d40e33596ef5e8f0f48dbbccab # v4.1.0
+        uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed # v4.3.0
         with:
           token: ${{ secrets.CODECOV_TOKEN }}
           slug: maguro/gslog