Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/golangci-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ jobs:
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
with:
version: v2.10.1
args: --timeout=8m
args: --timeout=8m --enable=sloglint
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,12 @@ This allows applications built with Tessera to block until leaves passed via cal
> [!Tip]
> This is useful if e.g. your application needs to return an inclusion proof in response to a request to add an entry to the log.

### Logging

Tessera utilizes the standard Go `log/slog` package for structured logging. By default, log events are emitted with their corresponding severity levels and key-value properties.

Personalities and operators of the log can customize how these logs are handled by configuring the default `slog.Handler`. For example, operators deploying to Google Cloud can set up an `slog.Handler` that automatically correlates logs with [OpenTelemetry distributed traces](https://cloud.google.com/logging/docs/structured-logging) using the context-aware properties from the incoming `http.Request`. The example programs in `/cmd/` provide reference initializations of these handlers for their respective environments.

## Lifecycles

### Appender
Expand Down
40 changes: 28 additions & 12 deletions cmd/conformance/gcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@ import (
"net/http"
"time"

"log/slog"
"os"

"github.com/transparency-dev/tessera"
"github.com/transparency-dev/tessera/internal/logger"
"github.com/transparency-dev/tessera/storage/gcp"
gcp_as "github.com/transparency-dev/tessera/storage/gcp/antispam"
"golang.org/x/mod/sumdb/note"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"k8s.io/klog/v2"
)

var (
Expand All @@ -40,6 +43,7 @@ var (
signer = flag.String("signer", "", "Note signer to use to sign checkpoints")
persistentAntispam = flag.Bool("antispam", false, "EXPERIMENTAL: Set to true to enable GCP-based persistent antispam storage")
traceFraction = flag.Float64("trace_fraction", 0.01, "Fraction of open-telemetry span traces to sample")
projectID = flag.String("project", "", "GCP Project ID for Cloud Logging traces (optional)")
additionalSigners = []string{}
)

Expand All @@ -51,10 +55,12 @@ func init() {
}

func main() {
klog.InitFlags(nil)
flag.Parse()
ctx := context.Background()

handler := slog.NewJSONHandler(os.Stderr, nil)
slog.SetDefault(slog.New(logger.NewGCPContextHandler(handler, *projectID)))

shutdownOTel := initOTel(ctx, *traceFraction)
defer shutdownOTel(ctx)

Expand All @@ -64,7 +70,8 @@ func main() {
gcpCfg := storageConfigFromFlags()
driver, err := gcp.New(ctx, gcpCfg)
if err != nil {
klog.Exitf("Failed to create new GCP storage: %v", err)
slog.Error("Failed to create new GCP storage", slog.Any("error", err))
os.Exit(1)
}

var antispam tessera.Antispam
Expand All @@ -73,7 +80,8 @@ func main() {
asOpts := gcp_as.AntispamOpts{} // Use defaults
antispam, err = gcp_as.NewAntispam(ctx, fmt.Sprintf("%s-antispam", *spanner), asOpts)
if err != nil {
klog.Exitf("Failed to create new GCP antispam storage: %v", err)
slog.Error("Failed to create new GCP antispam storage", slog.Any("error", err))
os.Exit(1)
}
}

Expand All @@ -84,7 +92,8 @@ func main() {
WithPushback(10*4096).
WithAntispam(tessera.DefaultAntispamInMemorySize, antispam))
if err != nil {
klog.Exit(err)
slog.Error("Failed to append", slog.Any("error", err))
os.Exit(1)
}

// Expose a HTTP handler for the conformance test writes.
Expand Down Expand Up @@ -121,25 +130,30 @@ func main() {
ReadHeaderTimeout: 5 * time.Second,
}
if err := http2.ConfigureServer(h1s, h2s); err != nil {
klog.Exitf("http2.ConfigureServer: %v", err)
slog.Error("http2.ConfigureServer", slog.Any("error", err))
os.Exit(1)
}

if err := h1s.ListenAndServe(); err != nil {
if err := shutdown(ctx); err != nil {
klog.Exit(err)
slog.Error("Failed to shutdown", slog.Any("error", err))
os.Exit(1)
}
klog.Exitf("ListenAndServe: %v", err)
slog.Error("ListenAndServe", slog.Any("error", err))
os.Exit(1)
}
}

// storageConfigFromFlags returns a gcp.Config struct populated with values
// provided via flags.
func storageConfigFromFlags() gcp.Config {
if *bucket == "" {
klog.Exit("--bucket must be set")
slog.Error("--bucket must be set")
os.Exit(1)
}
if *spanner == "" {
klog.Exit("--spanner must be set")
slog.Error("--spanner must be set")
os.Exit(1)
}
return gcp.Config{
Bucket: *bucket,
Expand All @@ -150,14 +164,16 @@ func storageConfigFromFlags() gcp.Config {
func signerFromFlags() (note.Signer, []note.Signer) {
s, err := note.NewSigner(*signer)
if err != nil {
klog.Exitf("Failed to create new signer: %v", err)
slog.Error("Failed to create new signer", slog.Any("error", err))
os.Exit(1)
}

var a []note.Signer
for _, as := range additionalSigners {
s, err := note.NewSigner(as)
if err != nil {
klog.Exitf("Failed to create additional signer: %v", err)
slog.Error("Failed to create additional signer", slog.Any("error", err))
os.Exit(1)
}
a = append(a, s)
}
Expand Down
15 changes: 10 additions & 5 deletions cmd/conformance/gcp/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import (
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"

"log/slog"
"os"

mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric"
texporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace"
"k8s.io/klog/v2"
)

// initOTel initialises the open telemetry support for metrics and tracing.
Expand All @@ -45,7 +47,7 @@ func initOTel(ctx context.Context, traceFraction float64) func(context.Context)
}
shutdownFuncs = nil
if err != nil {
klog.Errorf("OTel shutdown: %v", err)
slog.Error("OTel shutdown", slog.Any("error", err))
}
}

Expand All @@ -60,12 +62,14 @@ func initOTel(ctx context.Context, traceFraction float64) func(context.Context)
resource.WithDetectors(gcp.NewDetector()),
)
if err != nil {
klog.Exitf("Failed to detect resources: %v", err)
slog.Error("Failed to detect resources", slog.Any("error", err))
os.Exit(1)
}

me, err := mexporter.New()
if err != nil {
klog.Exitf("Failed to create metric exporter: %v", err)
slog.Error("Failed to create metric exporter", slog.Any("error", err))
os.Exit(1)
return nil
}
// initialize a MeterProvider that periodically exports to the GCP exporter.
Expand All @@ -78,7 +82,8 @@ func initOTel(ctx context.Context, traceFraction float64) func(context.Context)

te, err := texporter.New()
if err != nil {
klog.Exitf("Failed to create trace exporter: %v", err)
slog.Error("Failed to create trace exporter", slog.Any("error", err))
os.Exit(1)
return nil
}
// initialize a TracerProvier that periodically exports to the GCP exporter.
Expand Down
68 changes: 68 additions & 0 deletions internal/logger/gcp.go
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth having a package per infra - I'm not entirely sure whether the go linker will be able to ditch all the unneeded stuff if it's all in the same package.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that but figured we could do it later if needed as its all internal. This thing doesn't have any big deps other than some strings, so I figured this is safe. Am I underestimating some hidden cost?

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright 2026 The Tessera authors. All Rights Reserved.
//
// 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 logger provides structured logging utilities.
package logger

import (
"context"
"log/slog"

"go.opentelemetry.io/otel/trace"
)

// GCPContextHandler is an slog.Handler that extracts OpenTelemetry tracing
// information from the context and adds it to the log record in the format
// expected by GCP Cloud Logging, allowing logs to be correlated with traces.
type GCPContextHandler struct {
slog.Handler
projectID string
}

// NewGCPContextHandler wraps the provided slog.Handler. It injects GCP Cloud Logging
// compatible trace fields extracted from the context if a valid span is present.
func NewGCPContextHandler(h slog.Handler, projectID string) *GCPContextHandler {
return &GCPContextHandler{Handler: h, projectID: projectID}
}

// Handle adds the trace ID, span ID, and sampled flag to the record attributes.
func (h *GCPContextHandler) Handle(ctx context.Context, r slog.Record) error {
span := trace.SpanContextFromContext(ctx)
if span.IsValid() {
// GCP Cloud Logging expects the trace ID to be formatted as:
// projects/[PROJECT_ID]/traces/[TRACE_ID]
// https://docs.cloud.google.com/logging/docs/structured-logging#structured_logging_special_fields
tracePath := span.TraceID().String()
if h.projectID != "" {
tracePath = "projects/" + h.projectID + "/traces/" + tracePath
}

r.AddAttrs(
slog.String("logging.googleapis.com/trace", tracePath),
slog.String("logging.googleapis.com/spanId", span.SpanID().String()),
slog.Bool("logging.googleapis.com/trace_sampled", span.IsSampled()),
)
}
return h.Handler.Handle(ctx, r)
}

// WithAttrs returns a new handler with the given attributes, preserving the GCP handling.
func (h *GCPContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &GCPContextHandler{Handler: h.Handler.WithAttrs(attrs), projectID: h.projectID}
}

// WithGroup returns a new handler with the given group name, preserving the GCP handling.
func (h *GCPContextHandler) WithGroup(name string) slog.Handler {
return &GCPContextHandler{Handler: h.Handler.WithGroup(name), projectID: h.projectID}
}
10 changes: 6 additions & 4 deletions storage/gcp/antispam/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ import (
adminpb "cloud.google.com/go/spanner/admin/database/apiv1/databasepb"
"cloud.google.com/go/spanner/apiv1/spannerpb"

"log/slog"

"github.com/transparency-dev/tessera"
"github.com/transparency-dev/tessera/client"
"github.com/transparency-dev/tessera/internal/otel"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/codes"
"k8s.io/klog/v2"
)

const (
Expand Down Expand Up @@ -189,6 +190,7 @@ func (d *AntispamStorage) Decorator() func(f tessera.AddFn) tessera.AddFn {
//
// This implements tessera.Antispam.
func (d *AntispamStorage) Follower(b func([]byte) ([][]byte, error)) tessera.Follower {
ctx := context.Background()
f := &follower{
as: d,
bundleHasher: b,
Expand All @@ -203,7 +205,7 @@ func (d *AntispamStorage) Follower(b func([]byte) ([][]byte, error)) tessera.Fol
r, _ := base64.StdEncoding.DecodeString(warn)
gzr, _ := gzip.NewReader(bytes.NewReader([]byte(r)))
w, _ := io.ReadAll(gzr)
klog.Warningf("%s\nWarning: you're running under the Spanner emulator - this is not a supported environment!\n\n", string(w))
slog.WarnContext(ctx, string(w)+"\nWarning: you're running under the Spanner emulator - this is not a supported environment!\n\n")

// Hack in a workaround for spannertest not supporting BatchWrites
f.updateIndex = emulatorWorkaroundUpdateIndexTx
Expand Down Expand Up @@ -376,7 +378,7 @@ func (f *follower) Follow(ctx context.Context, lr tessera.LogReader) {
})
if err != nil {
if err != errOutOfSync {
klog.Errorf("Failed to commit antispam population tx: %v", err)
slog.ErrorContext(ctx, "Failed to commit antispam population tx", slog.Any("error", err))
}
stop()
next = nil
Expand Down Expand Up @@ -453,7 +455,7 @@ func createAndPrepareTables(ctx context.Context, spannerDB string, ddl []string,
}
defer func() {
if err := adminClient.Close(); err != nil {
klog.Warningf("adminClient.Close(): %v", err)
slog.WarnContext(ctx, "adminClient.Close() failed", slog.Any("error", err))
}
}()

Expand Down
Loading
Loading