wideslog

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 13 Imported by: 0

README

wideslog

PkgGoDev

Wide events for Go, built on top of log/slog.

wideslog collects logs from one operation and emits one structured record. Shared context is written once at the root; individual steps remain available inside events.

Payment API example

One payment request logs five steps. Each standard slog record repeats the full request context (service, request_id, tenant_id, user_id):

{"time":"2026-08-30T09:15:42.100Z","level":"INFO","msg":"request received","service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","method":"POST","path":"/v1/payments"}
{"time":"2026-08-30T09:15:42.101Z","level":"INFO","msg":"customer loaded","service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","customer_id":"cus_42A19C","customer_plan":"pro"}
{"time":"2026-08-30T09:15:42.103Z","level":"INFO","msg":"payment method verified","service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","brand":"visa","last4":"4242","risk_score":12}
{"time":"2026-08-30T09:15:42.105Z","level":"INFO","msg":"payout authorized","service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","payout_id":"pay_7D91B4","amount_cents":12990,"currency":"BRL"}
{"time":"2026-08-30T09:15:42.108Z","level":"INFO","msg":"payment completed","service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","payment_id":"pay_7D91B4","status":"confirmed"}

The same request through wideslog emits one record. NewEvent names the operation on the root; the shared context is written once as root attributes; each step keeps only its own fields inside events:

{"time":"2026-08-30T09:15:42.100Z","level":"INFO","msg":"charge payment pay_7D91B4","duration_ms":8,"event_count":5,"service":"payments-api","request_id":"req_01J9Y7K2","tenant_id":"tenant_acme","user_id":"usr_4821","events":[{"offset_ms":0,"level":"INFO","msg":"request received","method":"POST","path":"/v1/payments"},{"offset_ms":12,"level":"INFO","msg":"customer loaded","customer_id":"cus_42A19C","customer_plan":"pro"},{"offset_ms":32,"level":"INFO","msg":"payment method verified","brand":"visa","last4":"4242","risk_score":12},{"offset_ms":54,"level":"INFO","msg":"payout authorized","payout_id":"pay_7D91B4","amount_cents":12990,"currency":"BRL"},{"offset_ms":78,"level":"INFO","msg":"payment completed","payment_id":"pay_7D91B4","status":"confirmed"}]}

Five standard records become one line. Request metadata and the operation name are written once, while event-specific attributes stay with the event that produced them.

Savings simulation

Serialized as compact JSON with one trailing newline per record, the payment request above produces five records of 214, 224, 229, 240, and 224 bytes — 1,131 B per request. The wideslog output is one record of 763 bytes: 368 B saved per request (32.5%), before compression, indexing, or transport overhead.

Scenario Standard wideslog Byte savings
Payment API 5 lines / 1,131 B 1 line / 763 B 368 B (32.5%)

Using 86,400 seconds per day, 30 days per month, and 365 days per year at 50 requests per second:

Period Standard wideslog Bytes saved
day 4.89 GB 3.30 GB 1.59 GB
month 146.58 GB 98.88 GB 47.69 GB
year 1,783.36 GB 1,203.10 GB 580.26 GB

These are simulations, not universal benchmarks. Savings depend on event count, repeated context, field sizes, handler options, compression, and backend pricing. The line reduction is deterministic: five standard records become one wide record per operation. The byte savings come mostly from writing the shared request context and the per-line time/level/msg fields only once, at the root.

Quick start

Create the logger once during application startup:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})
logger := wideslog.New(handler)

Or use the JSON convenience constructor:

logger := wideslog.JSONHandler(os.Stdout, nil)

Create one event per request or operation:

func process(ctx context.Context, logger *slog.Logger) error {
    ctx, event := wideslog.NewEvent(ctx, logger, "checkout order "+orderID)
    defer event.End()

    event.Add(
        slog.String("request_id", "req_01J8X7"),
        slog.String("tenant_id", "tenant_acme"),
    )

    logger.InfoContext(ctx, "customer loaded", "customer_id", "cus_42A19C")
    logger.InfoContext(ctx, "payment authorized", "amount_cents", 12990)
    return nil
}

The message passed to NewEvent identifies the operation and becomes the message of the root record. Event.Add writes root attributes. Attributes passed to a log call remain on that event; steps logged through the logger appear as entries in events. Logs without an active event pass through to the wrapped handler.

Time modes

The root record always includes time and duration_ms. time is the moment the operation started; the level and msg are the ones slog always writes for the emitted record. Configure only the fields inside events:

ctx, event := wideslog.NewEvent(ctx, logger, "operation completed",
    wideslog.WithTimeMode(wideslog.TimeNone),
)

The per-step level and, in absolute mode, time live inside each events entry.

Available modes:

  • TimeNone: no timestamp on individual events.
  • TimeAbsolute: an ISO-8601 time on each event.
  • TimeOffset: an elapsed offset_ns, offset_us, or offset_ms.

The default is TimeOffset with OffsetMicroseconds:

ctx, event := wideslog.NewEvent(ctx, logger, "operation completed",
    wideslog.WithTimeMode(wideslog.TimeOffset),
    wideslog.WithOffsetUnit(wideslog.OffsetMilliseconds),
)

logger.InfoContext(ctx, "started")
time.Sleep(25 * time.Millisecond)
logger.InfoContext(ctx, "finished")
event.End()

What gets buffered

slog filters at the source: a step below the handler's configured level is never buffered and does not count toward event_count. End emits the root record at the highest level found among the buffered steps (Info when there are none), so a handler that only accepts serious levels still receives the summary.

Attributes attached to the logger itself (With, WithGroup) are shared context: they are written once on the root record, never repeated inside each step. Attributes passed to an individual log call stay with that step, in their original order.

event.Abort() discards the buffered steps and emits nothing — handy when the operation turns out not to need logging after all.

HTTP middleware

Create the event from the request context and pass the returned context forward:

func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        ctx, event := wideslog.NewEvent(r.Context(), logger, "request completed")
        defer event.End()

        next.ServeHTTP(w, r.WithContext(ctx))
        event.Add(slog.Int("http.status", 200))
    })
}

Each request receives its own event. The logger can be shared; request state is stored in the context and the Event.

OpenTelemetry

When the context carries an OpenTelemetry span, wideslog stamps trace_id and span_id on the emitted record, so a log backend (Loki, CloudWatch, ...) links the line to its trace:

ctx, span := tracer.Start(ctx, "checkout")
defer span.End()

ctx, event := wideslog.NewEvent(ctx, logger, "checkout")
// ... steps ...
event.End() // root record carries trace_id/span_id

Records logged outside an event are stamped too. The ids are captured when the event starts, so the wide event belongs to the span that was active then even if End runs after the span ended. The root record carries trace_id and span_id; each buffered step carries only span_id, because a step can run in a different span of the same trace (a nested DB call, for example).

Only go.opentelemetry.io/otel/trace is required (no SDK): without a configured OpenTelemetry provider there is no span, and the keys are simply absent — non-OpenTelemetry users pay nothing. The keys are exported as wideslog.TraceIDKey and wideslog.SpanIDKey.

To ship logs over OTLP, wrap the output handler with the OpenTelemetry slog bridge (e.g. a fan-out of slog.NewJSONHandler and otelslog.NewHandler); wideslog stays a plain slog.Handler.

When not to use it

Use standard slog when each line must be independently searchable or when an operation runs for a long time without useful checkpoints. Wide events are a summary of an operation, not a replacement for traces or fine-grained debug logs.

Example

See example/README.md and run:

go run ./example

Inspiration

wideslog is inspired by Wide Events, written by my friend Luiz Dubiela.

License

This project is licensed under the MIT License.

Documentation

Overview

Package wideslog collects slog records from one operation into a single structured record: a wide event.

Set up the logger once with New or JSONHandler, then wrap each operation:

ctx, event := wideslog.NewEvent(ctx, logger, "checkout")
event.Add(slog.String("request_id", "req-1"))
logger.InfoContext(ctx, "payment authorized")
event.End()

Log calls made with the returned context are buffered instead of being written immediately. End emits them as one record whose root carries the shared context, and the buffered lines live in the events field. Logs made without an active event pass through to the wrapped handler unchanged. Abort discards the buffered lines without emitting anything.

See the README and the example program for a full walkthrough.

Example
package main

import (
	"bytes"
	"context"
	"fmt"
	"log/slog"

	"github.com/lrweck/wideslog"
)

func main() {
	var buf bytes.Buffer
	logger := wideslog.JSONHandler(&buf, nil)

	ctx, event := wideslog.NewEvent(context.Background(), logger, "checkout")
	event.Add(slog.String("request_id", "req-1"))

	logger.InfoContext(ctx, "customer loaded", "customer_id", "cus_42A19C")
	logger.InfoContext(ctx, "payment authorized", "amount_cents", 12990)

	event.End()

	fmt.Println(buf.String())
}

Index

Examples

Constants

View Source
const (
	TraceIDKey = "trace_id"
	SpanIDKey  = "span_id"
)

Attribute keys wideslog uses when the context carries an OpenTelemetry span. They match the keys Grafana (Loki derived fields) and most log backends look for, so a log line links straight to its trace.

Variables

This section is empty.

Functions

func JSONHandler

func JSONHandler(
	w io.Writer,
	opts *slog.HandlerOptions,
) *slog.Logger

JSONHandler returns a logger backed by slog's JSONHandler.

func New

func New(next slog.Handler) *slog.Logger

New returns a logger backed by a wide-event Handler.

Types

type Config

type Config struct {
	TimeMode   TimeMode
	OffsetUnit OffsetUnit
}

Config defines the timestamp behavior of an Event.

func NewConfig

func NewConfig(options ...Option) Config

NewConfig returns the default configuration with options applied.

type Event

type Event struct {
	// contains filtered or unexported fields
}

Event collects request-scoped slog records into one wide event.

An Event is safe for concurrent use. This is useful when multiple goroutines contribute logs to the same request.

func FromContext

func FromContext(ctx context.Context) *Event

FromContext returns the Event stored in ctx, or nil when none is present.

func NewEvent

func NewEvent(
	ctx context.Context,
	logger *slog.Logger,
	msg string,
	options ...Option,
) (context.Context, *Event)

NewEvent begins collecting records for a request-scoped wide event.

msg identifies the operation and becomes the message of the root wide record. The returned context contains the Event. Any slog call made with this context is captured by a wideslog Handler.

NewEvent panics when logger is not backed by a wideslog Handler (use wideslog.New or wideslog.JSONHandler). A logger created with plain slog would silently emit records instead of buffering them.

NewEvent must be called on a context without an existing Event. Calling it twice stores the inner event in the context and the outer event stops collecting records.

func (*Event) Abort added in v0.1.2

func (e *Event) Abort()

Abort discards the event without emitting it, releasing the buffered records. Like End it is idempotent; any Add or log after it is ignored.

func (*Event) Add

func (e *Event) Add(attrs ...slog.Attr)

Add attaches attributes to the root of the final wide event.

These attributes are not added to individual events.

func (*Event) End

func (e *Event) End()

End emits the accumulated wide event, using the context captured at NewEvent.

End is idempotent. Only the first call emits the event, and no error is returned: an output handler that fails to write is treated the same way slog treats handler errors, silently.

The root record carries the message passed to NewEvent. Steps logged through the event's context, including the final one, live in the events field. The root record uses the highest level found in the buffered steps (Info when there are none), so handlers that only accept serious levels still receive it.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler buffers slog records when an Event is present in the context.

When no Event is present, it behaves like the wrapped slog.Handler.

func NewHandler

func NewHandler(next slog.Handler) *Handler

NewHandler returns a Handler that wraps next.

func (*Handler) Enabled

func (h *Handler) Enabled(
	ctx context.Context,
	level slog.Level,
) bool

Enabled reports whether the wrapped handler accepts level.

This preserves normal slog filtering behavior.

func (*Handler) Handle

func (h *Handler) Handle(
	ctx context.Context,
	record slog.Record,
) error

Handle buffers record in the active Event or forwards it to the wrapped handler when no Event exists.

Levels the wrapped handler rejects are not buffered; filtering is enforced when the record arrives, matching what slog would do without an Event.

Only the attributes the logging handler adds beyond the Event's root scopes are attached to the buffered record: shared attributes are stripped from the matching scopes, so the group skeleton stays but the shared context is written once at the root.

func (*Handler) WithAttrs

func (h *Handler) WithAttrs(
	attrs []slog.Attr,
) slog.Handler

WithAttrs returns a child handler with attrs attached to subsequent records.

The parent handler is never modified, matching slog.Handler semantics.

func (*Handler) WithGroup

func (h *Handler) WithGroup(name string) slog.Handler

WithGroup returns a child handler that nests subsequent attributes under name.

Groups are scoped to this logger and its descendants.

type OffsetUnit

type OffsetUnit uint8

OffsetUnit selects the unit used for relative event timestamps.

const (
	// OffsetNanoseconds records offsets in nanoseconds.
	OffsetNanoseconds OffsetUnit = iota

	// OffsetMicroseconds records offsets in microseconds.
	OffsetMicroseconds

	// OffsetMilliseconds records offsets in milliseconds.
	OffsetMilliseconds
)

func (OffsetUnit) String

func (u OffsetUnit) String() string

type Option

type Option func(*Config)

Option configures an Event created by NewEvent.

func WithOffsetUnit

func WithOffsetUnit(unit OffsetUnit) Option

WithOffsetUnit sets the unit used when TimeOffset is enabled.

func WithTimeMode added in v0.1.2

func WithTimeMode(mode TimeMode) Option

WithTimeMode sets how individual event timestamps are represented.

type TimeMode added in v0.1.2

type TimeMode uint8

TimeMode controls how timestamps are recorded for individual events.

const (
	// TimeNone omits timestamps from individual events.
	TimeNone TimeMode = iota

	// TimeAbsolute records each event's wall-clock timestamp.
	TimeAbsolute

	// TimeOffset records each event's elapsed time from the wide event start.
	TimeOffset
)

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL