promolog

package module
v0.2.27 Latest Latest
Warning

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

Go to latest
Published: Apr 9, 2026 License: MIT Imports: 17 Imported by: 1

README

promolog

image

Go Reference

promolog

Per-request log capture with policy-driven promotion for Go.

past is already past -- don't debug it

-- Layman Grug

Grug was almost right. But when a policy matches -- an error, a slow response, an admin action, a sampled baseline -- the past is exactly what you need. Promolog says: buffer the past, discard it when it doesn't matter, and promote it when a policy says it does.

The mental model is simple: buffer every request, promote based on policies. An error is one policy. A slow checkout is another. An admin audit trail is a third. A 1% sample of all traffic is a fourth. The mechanism is the same -- the policy decides.

Why

Without promolog:

// Every request logs everything. 99% of it is noise.
func handler(w http.ResponseWriter, r *http.Request) {
    slog.Info("parsing request body")
    slog.Info("validating input", "field", "email")
    slog.Info("querying database", "table", "users")
    // ...request succeeds. These logs are useless.
    // But when something goes wrong -- or you need an audit trail --
    // you wish you had more context.
    slog.Error("database timeout", "err", err)
    // Good luck finding the 5 log lines that led to this.
}

With promolog:

// Normal requests: logs buffered in memory, then discarded. Zero noise.
// Policy match: entire request trace promoted to storage. Full context.

// Automatic: policies decide what gets promoted.
handler := promolog.CorrelationMiddleware(
    promolog.AutoPromoteMiddleware(store,
        promolog.StatusPolicy(500),                        // server errors
        promolog.LatencyPolicy(2 * time.Second),           // slow requests
        promolog.SamplePolicy(0.01, nil),                  // 1% baseline
        promolog.RoutePolicy("/admin/*", func(int) bool {  // audit trail
            return true
        }),
    )(mux),
)

// Manual: match specific error types yourself, promote the same way.
func errorHandler(store promolog.Storer) func(err error, w http.ResponseWriter, r *http.Request) {
    return func(err error, w http.ResponseWriter, r *http.Request) {
        statusCode := http.StatusInternalServerError
        var apiErr *APIError
        if errors.As(err, &apiErr) {
            statusCode = apiErr.Code
        }

        if buf := promolog.GetBuffer(r.Context()); buf != nil {
            store.Promote(r.Context(), promolog.Trace{
                RequestID:  promolog.GetRequestID(r.Context()),
                ErrorChain: err.Error(),
                StatusCode: statusCode,
                Route:      r.URL.Path,
                Method:     r.Method,
                RemoteIP:   r.RemoteAddr,
                Entries:    buf.Entries(),
            })
        }

        http.Error(w, err.Error(), statusCode)
    }
}

During normal requests, log records are buffered in memory and discarded. When a policy matches -- whether it's a status code, latency threshold, route pattern, sample rate, or custom predicate -- the full buffer is promoted to the store. You get every log line from the request, with context attached. Both paths (automatic policies and manual promote) write the same Trace to the same store.

Install

The core library has zero external dependencies:

go get github.com/catgoose/promolog

For the SQLite-backed store:

go get github.com/catgoose/promolog/sqlite

The server does not remember you. The server has already forgotten you. The server has moved on.

-- The Wisdom of the Uniform Interface

Unless a policy says otherwise. Then the server remembers everything.

How it works

  1. CorrelationMiddleware attaches a Buffer, request ID, and start time to the context
  2. A slog.Handler wrapper captures every log record into the buffer
  3. AutoPromoteMiddleware evaluates promotion policies after the handler returns
  4. If any policy matches, the buffer is promoted to the store automatically
  5. Query the store later to see exactly what happened
request in --> middleware --> buffer logs --> policies match?
                                         \-> no:  discard
                                         \-> yes: promote to store

Quick start

import (
    "database/sql"
    "log/slog"
    "net/http"
    "time"

    "github.com/catgoose/promolog"
    "github.com/catgoose/promolog/sqlite"
    _ "github.com/mattn/go-sqlite3"
)

// 1. Set up the store
db, _ := sql.Open("sqlite3", "traces.db")
store := sqlite.NewStore(db)
store.InitSchema()
store.StartCleanup(ctx, 90*24*time.Hour, time.Hour)

// 2. Wrap your slog handler
logger := slog.New(promolog.NewHandler(slog.Default().Handler()))
slog.SetDefault(logger)

// 3. Define your promotion policies
policies := []promolog.PromotionPolicy{
    promolog.StatusPolicy(500),                        // all server errors
    promolog.LatencyPolicy(2 * time.Second),           // slow requests
    promolog.SamplePolicy(0.01, nil),                  // 1% of everything
    promolog.RoutePolicy("/admin/*", func(int) bool {  // all admin access
        return true
    }),
}

// 4. Wire up the middleware stack
mux := http.NewServeMux()
handler := promolog.CorrelationMiddleware(
    promolog.AutoPromoteMiddleware(store, policies...)(mux),
)
http.ListenAndServe(":8080", handler)

Grug say: "complexity is apex predator." Student say: "how do I defeat the complexity?" Grug say: "no."

-- Layman Grug

Promolog says "no" to log noise. The policy decides what matters. Everything else is discarded.

Promotion policies

Promotion is policy-driven: a predicate and a name. The default policy is status >= 500 -> promote, but "error" is just one policy. Users define additional policies for audit trails, slow requests, sampling, and noise suppression.

Built-in policies
// Server errors (status >= 500)
promolog.StatusPolicy(500)

// Route-specific: always promote admin access
promolog.RoutePolicy("/admin/*", func(code int) bool { return true })

// Route-specific: promote 4xx+ on API routes
promolog.RoutePolicy("/api/*", func(code int) bool { return code >= 400 })

// Latency: promote anything over 2 seconds
promolog.LatencyPolicy(2 * time.Second)

// Sampling: promote 1% of all requests for baseline visibility
promolog.SamplePolicy(0.01, nil)

// Sampling with deterministic RNG (for tests)
rng := rand.New(rand.NewSource(42))
promolog.SamplePolicy(0.05, rng)

Custom policies are just a PromotionPolicy struct:

promolog.PromotionPolicy{
    Name: "high-value-user",
    Predicate: func(r *http.Request, statusCode int) bool {
        return r.Header.Get("X-User-Tier") == "enterprise"
    },
}
Auto-promote middleware

AutoPromoteMiddleware captures the response status code and evaluates policies automatically -- no manual Promote calls needed:

promolog.AutoPromoteMiddleware(store,
    promolog.StatusPolicy(500),
    promolog.LatencyPolicy(2 * time.Second),
)(mux)

The middleware wraps ResponseWriter to capture the status code, runs the downstream handler, then checks each policy. First match wins.

Runtime filter rules

For rules that change without redeployment, store them in SQLite:

// Create a rule to suppress Chrome DevTools noise
store.CreateRule(ctx, promolog.FilterRule{
    Name:     "devtools noise",
    Field:    "route",
    Operator: "starts_with",
    Value:    "/favicon",
    Action:   "suppress",
    Enabled:  true,
})

// Always capture admin requests
store.CreateRule(ctx, promolog.FilterRule{
    Name:     "admin audit",
    Field:    "route",
    Operator: "matches_glob",
    Value:    "/admin/*",
    Action:   "always_promote",
    Enabled:  true,
})

// Load rules into an engine for fast evaluation
engine, _ := store.LoadRuleEngine(ctx)
action, matched := engine.Match(promolog.TraceFields(trace))

Available actions: suppress, always_promote, tag, short_ttl.

Manual promotion

When your framework has its own error handler or you need to match specific error types before promoting, call Promote directly. This is how dothog uses promolog -- the error handler inspects the error, determines the status code, and promotes with the full request context:

func errorHandler(store promolog.Storer) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        statusCode := http.StatusInternalServerError
        var apiErr *APIError
        if errors.As(err, &apiErr) {
            statusCode = apiErr.Code
        }

        if buf := promolog.GetBuffer(r.Context()); buf != nil {
            store.Promote(r.Context(), promolog.Trace{
                RequestID:  promolog.GetRequestID(r.Context()),
                ErrorChain: err.Error(),
                StatusCode: statusCode,
                Route:      r.URL.Path,
                Method:     r.Method,
                Entries:    buf.Entries(),
            })
        }
    }
}

Manual and automatic promotion use the same Trace struct and Storer interface. Use AutoPromoteMiddleware for policy-driven promotion, manual Promote for framework-specific error handling, or both together.

Middleware stack

Correlation

CorrelationMiddleware sets up per-request state: a unique request ID, a Buffer for log capture, and a start time for latency tracking.

// Basic usage
handler := promolog.CorrelationMiddleware(mux)

// With a buffer entry limit (prevents memory blowup)
handler := promolog.CorrelationMiddlewareWithLimit(500)(mux)

It reads X-Request-ID from incoming requests. When present, the incoming ID becomes the parent and a fresh child ID is generated for this service's span.

Body capture

BodyCaptureMiddleware captures request and response bodies into the buffer for inclusion in promoted traces:

promolog.BodyCaptureMiddleware(
    promolog.WithMaxBodySize(64 * 1024),    // default: 64 KiB
    promolog.WithRedactor(func(body []byte) []byte {
        // strip sensitive fields before storage
        return redact(body)
    }),
)

Bodies are opt-in, truncated to a configurable limit, and support redaction hooks for stripping passwords, tokens, or PII.

Buffer limits

A handler logging in a tight loop could buffer thousands of entries. Cap it:

// Via middleware
promolog.CorrelationMiddlewareWithLimit(500)

// Via handler option
promolog.NewHandler(inner, promolog.WithBufferLimit(500))

When the limit is exceeded, the buffer keeps the first half and last half of entries (preserving how the request started and ended) and inserts a synthetic WARN entry noting how many middle entries were elided.

Putting it together
mux := http.NewServeMux()

handler := promolog.CorrelationMiddlewareWithLimit(500)(
    promolog.BodyCaptureMiddleware(
        promolog.WithMaxBodySize(32 * 1024),
    )(
        promolog.AutoPromoteMiddleware(store,
            promolog.StatusPolicy(500),
            promolog.LatencyPolicy(2 * time.Second),
        )(mux),
    ),
)

Order matters: Correlation first (sets up context), then BodyCapture (reads/wraps bodies), then AutoPromote (evaluates policies and promotes).

Distributed tracing

Propagate request IDs across service boundaries:

// Outbound: wrap your HTTP client
client := promolog.NewCorrelatedClient(http.DefaultClient)

// Or wrap just the transport
transport := promolog.CorrelationTransport(http.DefaultTransport)
client := &http.Client{Transport: transport}

When service A calls service B, the X-Request-ID header is set automatically. Service B's CorrelationMiddleware picks it up as the parent ID and generates a child ID. The ParentRequestID field on Trace links them.

// In service B, the trace includes:
trace.ParentRequestID // service A's request ID
trace.RequestID       // service B's own ID

Trace tags

Attach arbitrary key-value tags to the buffer for higher-level categorization:

buf := promolog.GetBuffer(r.Context())
buf.Tag("feature", "checkout")
buf.Tag("tenant", "acme")
buf.Tag("source", "internal")

Tags are stored alongside the trace, queryable via TraceFilter, and surfaced in AvailableFilters for building filter dropdowns.

Querying traces

// Get a single trace with full log entries
trace, err := store.Get(ctx, "req-abc-123")

// List traces with filtering, search, sorting, and pagination
rows, total, err := store.ListTraces(ctx, promolog.TraceFilter{
    Q:       "connection",         // full-text search
    Status:  "5xx",                // "4xx", "5xx", or exact like "502"
    Method:  "POST",
    Tags:    map[string]string{    // filter by tags (AND semantics)
        "feature": "checkout",
    },
    Sort:    "StatusCode",         // CreatedAt, StatusCode, Route, Method
    Dir:     "desc",
    Page:    1,
    PerPage: 25,
})

// Delete a trace
store.DeleteTrace(ctx, "req-abc-123")
Aggregation

Group traces by dimension and surface patterns:

results, err := store.Aggregate(ctx, promolog.AggregateFilter{
    GroupBy:  "route",            // "route", "status_code", "method", "error_chain"
    Window:   1 * time.Hour,     // time window
    MinCount: 5,                 // minimum traces per group
})
// [{Key: "/api/users", Count: 47, TopErrors: ["connection refused", "timeout"]}]
Available filters

Build filter dropdowns from actual data:

opts, _ := store.AvailableFilters(ctx, promolog.TraceFilter{})
// opts.StatusCodes: []int{400, 500, 502}
// opts.Methods:     []string{"GET", "POST"}
// opts.Routes:      []string{"/api/users", "/admin/settings"}
// opts.RemoteIPs:   []string{"10.0.0.1", "192.168.1.100"}
// opts.UserIDs:     []string{"user-42", "admin-1"}
// opts.TagKeys:     []string{"feature", "tenant"}
// opts.Tags:        map[string][]string{"feature": ["checkout", "search"]}

Retention policies

Different traces deserve different lifetimes:

// Keep 5xx traces for 90 days
store.CreateRetentionRule(ctx, promolog.RetentionRule{
    Name:     "server errors",
    Field:    "status_code",
    Operator: "starts_with",
    Value:    "5",
    TTLHours: 90 * 24,
    Enabled:  true,
})

// Keep 4xx traces for 7 days
store.CreateRetentionRule(ctx, promolog.RetentionRule{
    Name:     "client errors",
    Field:    "status_code",
    Operator: "starts_with",
    Value:    "4",
    TTLHours: 7 * 24,
    Enabled:  true,
})

// Admin audit trails: 180 days
store.CreateRetentionRule(ctx, promolog.RetentionRule{
    Name:     "admin audit",
    Field:    "route",
    Operator: "starts_with",
    Value:    "/admin",
    TTLHours: 180 * 24,
    Enabled:  true,
})

The cleanup goroutine evaluates retention rules per trace. Traces matching a rule use that rule's TTL. Unmatched traces use the global default. When multiple rules match, the shortest TTL wins.

Export adapters

Export promoted traces to external systems without blocking the promote path:

import (
    jsonexport "github.com/catgoose/promolog/export/json"
    "github.com/catgoose/promolog/export/webhook"
)

// Structured JSON lines to stdout
exporter := jsonexport.New(os.Stdout, jsonexport.WithPretty())

// Webhook: POST to an endpoint
exporter := webhook.New("https://example.com/traces",
    webhook.WithTimeout(5 * time.Second),
    webhook.WithHeader("Authorization", "Bearer token"),
)

// Wire it up -- exports run async, never block promotes
promolog.WireExporter(store, exporter, store.Get)

The Exporter interface is simple:

type Exporter interface {
    Export(ctx context.Context, trace Trace) error
    Close() error
}

Write your own for Datadog, Loki, Slack, PagerDuty -- whatever your infrastructure needs.

Bring your own store

The core library defines a Storer interface. The SQLite implementation lives in github.com/catgoose/promolog/sqlite, but you can implement Storer with any backend:

type Storer interface {
    InitSchema() error
    SetOnPromote(fn func(TraceSummary))
    Promote(ctx context.Context, trace Trace) error
    PromoteAt(ctx context.Context, trace Trace, createdAt time.Time) error
    Get(ctx context.Context, requestID string) (*Trace, error)
    ListTraces(ctx context.Context, f TraceFilter) ([]TraceSummary, int, error)
    AvailableFilters(ctx context.Context, f TraceFilter) (FilterOptions, error)
    DeleteTrace(ctx context.Context, requestID string) error
    StartCleanup(ctx context.Context, ttl time.Duration, interval time.Duration)
    CreateRule(ctx context.Context, rule FilterRule) (FilterRule, error)
    ListRules(ctx context.Context) ([]FilterRule, error)
    UpdateRule(ctx context.Context, rule FilterRule) error
    DeleteRule(ctx context.Context, id int) error
    CreateRetentionRule(ctx context.Context, rule RetentionRule) (RetentionRule, error)
    ListRetentionRules(ctx context.Context) ([]RetentionRule, error)
    UpdateRetentionRule(ctx context.Context, rule RetentionRule) error
    DeleteRetentionRule(ctx context.Context, id int) error
    Aggregate(ctx context.Context, f AggregateFilter) ([]AggregateResult, error)
}

Duplicate handling

Promote returns promolog.ErrDuplicateTrace if a trace with the same request ID already exists. The SetOnPromote callback only fires on successful inserts.

err := store.Promote(ctx, trace)
if errors.Is(err, promolog.ErrDuplicateTrace) {
    // already recorded
}

Testing

Storer is an interface -- mock it in your application tests.

SamplePolicy accepts an optional *rand.Rand for deterministic test behavior. LatencyPolicy reads the start time from context, so you can control it in tests by setting startTimeKey via CorrelationMiddleware.

API reference

Core (github.com/catgoose/promolog) -- zero dependencies
Type / Function Description
Storer Interface for trace persistence
Exporter Interface for exporting traces to external systems
Handler slog.Handler wrapper that captures records into a per-request buffer
NewHandler(inner, ...option) Creates a Handler wrapping an existing slog handler
Buffer Thread-safe per-request log buffer with optional size limits
Trace Full trace with entries, bodies, tags, and parent request ID
TraceSummary Lightweight trace without entries (for list views)
TraceFilter Query parameters for ListTraces and AvailableFilters
FilterOptions Distinct values for filter dropdowns (status, method, route, IP, user, tags)
FilterRule Runtime suppress/promote/tag rule stored in the database
RuleEngine In-memory rule evaluator for fast matching
RetentionRule Per-route/status retention policy with custom TTL
AggregateFilter Grouping parameters for trace aggregation
AggregateResult Aggregation bucket with count and top errors
PromotionPolicy Predicate-based promotion decision (status, route, latency, sample)
StatusPolicy(minCode) Promotes when status >= minCode
RoutePolicy(pattern, fn) Promotes when route matches and fn returns true
LatencyPolicy(threshold) Promotes when request duration exceeds threshold
SamplePolicy(rate, rng) Promotes a random fraction of requests
CorrelationMiddleware Sets up request ID, parent ID, buffer, and start time
AutoPromoteMiddleware Evaluates policies and promotes automatically
BodyCaptureMiddleware Captures request/response bodies into the buffer
CorrelationTransport http.RoundTripper that propagates request IDs on outbound calls
NewCorrelatedClient Creates an http.Client with request ID propagation
WireExporter Connects an Exporter to a store's OnPromote callback
ErrDuplicateTrace Sentinel error for duplicate request IDs
SQLite store (github.com/catgoose/promolog/sqlite)
Type / Function Description
Store SQLite-backed implementation of promolog.Storer
NewStore(db) Constructor -- pass a *sql.DB opened with a SQLite driver
Export packages
Package Description
github.com/catgoose/promolog/export/json JSON lines exporter to any io.Writer
github.com/catgoose/promolog/export/webhook HTTP POST exporter to a configurable endpoint

Philosophy

Grug's last teaching: past is already past -- don't debug it. future not here yet -- don't optimize for it. server return html -- this present moment.

-- Layman Grug

Promolog amends the teaching: the past is past -- unless a policy says otherwise. Then the past is exactly what you need, and promolog kept it for you.

If you are building something that must evolve -- while clients depend on it, while teams change, while requirements shift, while Kevin goes on PTO and comes back and the new Kevin doesn't know the old Kevin's conventions -- then you need an architecture that permits change without breaking the contract.

-- The Wisdom of the Uniform Interface

Promolog is that architecture for your request traces. The policy is the contract. When Kevin comes back from PTO, the full request context is waiting in the store. The admin audit trail is there. The slow checkout that preceded the outage is there. The 1% sample of baseline traffic is there. Kevin doesn't need to know what happened. The store knows what happened.

Promolog follows the dothog design philosophy: zero dependencies in the core, interface-driven extensibility, and the server handles state so you don't have to.

Architecture

  request in ──► CorrelationMiddleware ──► BodyCaptureMiddleware ──► AutoPromoteMiddleware ──► handler
                      │                         │                          │                      │
                      │  attach Buffer,         │  capture req/res         │  evaluate            │  slog calls
                      │  request ID,            │  bodies into             │  policies             │  captured by
                      │  parent ID,             │  Buffer                  │  after handler        │  Handler
                      │  start time             │                          │  returns              │     │
                      │                         │                          │                       │     v
                      │                         │                          │                  ┌─────────┐
                      │                         │                          │                  │ Buffer   │
                      │                         │                          │                  │ (memory) │
                      │                         │                          │                  └────┬─────┘
                      │                         │                          │                       │
                      │                         │                    policy matches?               │
                      │                         │                       │          │               │
                      │                         │                      no         yes              │
                      │                         │                       │          │               │
                      │                         │                    discard    Promote()          │
                      │                         │                                  │               │
                      │                         │                             ┌────v──────┐        │
                      │                         │                             │  Store    │        │
                      │                         │                             │ (SQLite)  │        │
                      │                         │                             └────┬──────┘        │
                      │                         │                                  │               │
                      │                         │                             ┌────v──────┐        │
                      │                         │                             │ Exporters │        │
                      │                         │                             │ (async)   │        │
                      │                         │                             └───────────┘        │

License

MIT

Documentation

Overview

Package promolog provides per-request trace capture with promote-on-error semantics. Each request buffers its slog records locally; only when an error occurs is the buffer promoted to a Storer implementation for later retrieval. The core package has zero external dependencies. See github.com/catgoose/promolog/sqlite for a SQLite-backed Storer.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrDuplicateTrace = errors.New("promolog: duplicate request ID")

ErrDuplicateTrace is returned when a trace with the same request ID already exists.

View Source
var RequestIDKey = requestIDKeyType{}

Functions

func AutoPromoteMiddleware added in v0.2.21

func AutoPromoteMiddleware(store Storer, policies ...PromotionPolicy) func(http.Handler) http.Handler

AutoPromoteMiddleware returns middleware that captures the response status code and evaluates the given PromotionPolicy values after the downstream handler returns. If any policy matches, the request's buffer is promoted to the store automatically — no manual Promote call is needed.

This middleware must be applied after CorrelationMiddleware so that the request context contains both a request ID and a Buffer.

Manual promotion (calling store.Promote directly) remains available as an escape hatch for cases not covered by policies.

Usage:

policies := []promolog.PromotionPolicy{
    promolog.StatusPolicy(500),
}
mux := http.NewServeMux()
handler := promolog.CorrelationMiddleware(
    promolog.AutoPromoteMiddleware(store, policies...)(mux),
)

func BodyCaptureMiddleware added in v0.2.21

func BodyCaptureMiddleware(opts ...BodyCaptureOption) func(http.Handler) http.Handler

BodyCaptureMiddleware returns middleware that captures request and response bodies into the per-request Buffer. It must be applied after CorrelationMiddleware so that the context contains a Buffer.

Usage:

handler := promolog.CorrelationMiddleware(
    promolog.BodyCaptureMiddleware()(
        promolog.AutoPromoteMiddleware(store, policies...)(mux),
    ),
)

func CorrelationMiddleware

func CorrelationMiddleware(next http.Handler) http.Handler

CorrelationMiddleware is stdlib HTTP middleware that sets up per-request correlation for promolog. It generates a unique request ID (or reuses one from the incoming X-Request-ID header), sets the X-Request-ID response header, stores the ID in the request context, and initializes a promolog Buffer for log capture.

Usage with net/http:

mux := http.NewServeMux()
http.ListenAndServe(":8080", promolog.CorrelationMiddleware(mux))

Usage with Echo:

e.Use(echo.WrapMiddleware(promolog.CorrelationMiddleware))
Example
package main

import (
	"fmt"
)

func main() {
	// CorrelationMiddleware generates a request ID, sets the X-Request-ID
	// header, and initializes a promolog Buffer on each request's context.
	// See the package-level docs for HTTP handler usage.
	fmt.Println("wrap with promolog.CorrelationMiddleware(handler)")
}
Output:
wrap with promolog.CorrelationMiddleware(handler)

func CorrelationMiddlewareWithLimit added in v0.2.20

func CorrelationMiddlewareWithLimit(limit int) func(http.Handler) http.Handler

CorrelationMiddlewareWithLimit works like CorrelationMiddleware but initialises the per-request Buffer with the given entry limit. A limit of 0 means unlimited.

func CorrelationTransport added in v0.2.21

func CorrelationTransport(base http.RoundTripper) http.RoundTripper

CorrelationTransport returns an http.RoundTripper that reads the request ID from the outgoing request's context and sets it as the X-Request-ID header. If no request ID is present in the context the request is passed through unmodified. When base is nil, http.DefaultTransport is used.

func GetParentRequestID added in v0.2.21

func GetParentRequestID(ctx context.Context) string

GetParentRequestID retrieves the parent request ID from the context, or returns an empty string if none is set.

func GetRequestDuration added in v0.2.21

func GetRequestDuration(r *http.Request) time.Duration

GetRequestDuration returns the elapsed time since the request started. If no start time is present in the context it returns 0.

func GetRequestID

func GetRequestID(ctx context.Context) string

GetRequestID retrieves the request ID from the context, or returns an empty string if none is set.

func GetRequestStartTime added in v0.2.21

func GetRequestStartTime(ctx context.Context) time.Time

GetRequestStartTime retrieves the request start time from the context, or returns the zero time if none is set.

func NewBufferContext

func NewBufferContext(ctx context.Context) context.Context

NewBufferContext returns a new context with an empty, unlimited Buffer attached. For a size-limited buffer, use NewBufferContextWithLimit.

func NewBufferContextWithLimit added in v0.2.20

func NewBufferContextWithLimit(ctx context.Context, limit int) context.Context

NewBufferContextWithLimit returns a new context with a size-limited Buffer. The limit caps the number of entries kept. When the limit is exceeded the buffer retains the first and last entries and inserts a synthetic entry noting how many middle entries were elided. A limit of 0 means unlimited.

func NewCorrelatedClient added in v0.2.21

func NewCorrelatedClient(base *http.Client) *http.Client

NewCorrelatedClient returns a shallow copy of base (or http.DefaultClient when base is nil) whose transport propagates request IDs via the X-Request-ID header. The original client is not modified.

func StatusCodeStr added in v0.2.21

func StatusCodeStr(code int) string

func TraceFields added in v0.2.21

func TraceFields(t Trace) map[string]string

TraceFields extracts the standard field map from a Trace, suitable for passing to RuleEngine.Match.

func WireExporter added in v0.2.21

func WireExporter(store Storer, exporter Exporter, getTrace func(ctx context.Context, requestID string) (*Trace, error))

WireExporter connects an Exporter to a Storer's OnPromote callback so that every promoted trace is exported asynchronously. The export runs in a separate goroutine to avoid blocking the promote path.

To use multiple exporters, call WireExporter once for each.

Note: because SetOnPromote replaces any previously registered callback, this helper wraps the existing callback (if any) so both are invoked.

Types

type AggregateFilter added in v0.2.21

type AggregateFilter struct {
	GroupBy  string        // "route", "status_code", "method", "error_chain"
	Window   time.Duration // time window to aggregate over
	MinCount int           // minimum count to include in results
}

AggregateFilter controls how traces are grouped for aggregation.

type AggregateResult added in v0.2.21

type AggregateResult struct {
	Key       string   // the grouped value (e.g., "/api/users")
	Count     int      // number of traces in this group
	TopErrors []string // most common error chains in this group
}

AggregateResult is a single aggregation bucket.

type BodyCaptureOption added in v0.2.21

type BodyCaptureOption func(*bodyCaptureConfig)

BodyCaptureOption configures the BodyCaptureMiddleware.

func WithMaxBodySize added in v0.2.21

func WithMaxBodySize(n int) BodyCaptureOption

WithMaxBodySize sets the maximum number of bytes captured from the request and response bodies. Bodies larger than this are truncated. The default is 64 KiB.

func WithRedactor added in v0.2.21

func WithRedactor(fn func(body []byte) []byte) BodyCaptureOption

WithRedactor registers a function that is applied to captured bodies before they are stored in the Buffer. Use this to strip sensitive data such as passwords or tokens.

type Buffer

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

Buffer is a per-request log buffer stored in the request context. It is safe for concurrent use.

When a limit is set (via NewBuffer), the buffer keeps the first half and last half of entries. Middle entries are dropped and replaced with a synthetic entry indicating how many were elided. A limit of 0 means unlimited.

func GetBuffer

func GetBuffer(ctx context.Context) *Buffer

GetBuffer retrieves the per-request Buffer from the context, or nil.

Example
package main

import (
	"context"
	"fmt"
	"time"

	"github.com/catgoose/promolog"
)

func main() {
	// Attach a buffer to the context.
	ctx := promolog.NewBufferContext(context.Background())

	buf := promolog.GetBuffer(ctx)
	buf.Append(promolog.Entry{
		Time:    time.Now(),
		Level:   "INFO",
		Message: "manual entry",
	})

	fmt.Println(len(buf.Entries()))

	// Without a buffer, GetBuffer returns nil.
	empty := promolog.GetBuffer(context.Background())
	fmt.Println(empty)
}
Output:
1
<nil>

func NewBuffer added in v0.2.20

func NewBuffer(limit int) *Buffer

NewBuffer creates a Buffer with the given entry limit. A limit of 0 means unlimited (the same as using &Buffer{} directly).

func (*Buffer) Append

func (b *Buffer) Append(e Entry)

Append adds an entry to the buffer. It is safe for concurrent use.

func (*Buffer) Entries

func (b *Buffer) Entries() []Entry

Entries returns a copy of the current entries. It is safe for concurrent use. When a limit is active and entries were elided, a synthetic entry is inserted between the head and tail portions indicating how many entries were dropped.

func (*Buffer) RequestBody added in v0.2.21

func (b *Buffer) RequestBody() string

RequestBody returns the stored request body. It is safe for concurrent use.

func (*Buffer) ResponseBody added in v0.2.21

func (b *Buffer) ResponseBody() string

ResponseBody returns the stored response body. It is safe for concurrent use.

func (*Buffer) SetRequestBody added in v0.2.21

func (b *Buffer) SetRequestBody(body string)

SetRequestBody stores the request body in the buffer. It is safe for concurrent use.

func (*Buffer) SetResponseBody added in v0.2.21

func (b *Buffer) SetResponseBody(body string)

SetResponseBody stores the response body in the buffer. It is safe for concurrent use.

func (*Buffer) Snapshot deprecated

func (b *Buffer) Snapshot() []Entry

Snapshot returns a copy of the current entries. It is safe for concurrent use.

Deprecated: Use Entries instead.

func (*Buffer) Tag added in v0.2.21

func (b *Buffer) Tag(key, value string)

Tag sets a key-value tag on the buffer. Tags are included in the Trace when the buffer is promoted. It is safe for concurrent use.

func (*Buffer) Tags added in v0.2.21

func (b *Buffer) Tags() map[string]string

Tags returns a copy of the current tags. It is safe for concurrent use.

type Entry

type Entry struct {
	Time    time.Time         `json:"time"`
	Level   string            `json:"level"`
	Message string            `json:"msg"`
	Attrs   map[string]string `json:"attrs,omitempty"`
}

Entry is a single captured log record.

type Exporter added in v0.2.21

type Exporter interface {
	// Export sends a single trace to the export destination.
	Export(ctx context.Context, trace Trace) error
	// Close flushes any buffered data and releases resources.
	Close() error
}

Exporter defines the interface for exporting promoted traces to external systems. Implementations live in the export/ subpackages.

type FilterOptions

type FilterOptions struct {
	StatusCodes []int
	Methods     []string
	TagKeys     []string // distinct tag keys across all traces
	RemoteIPs   []string
	Routes      []string
	UserIDs     []string
	Tags        map[string][]string // distinct values per tag key
}

FilterOptions holds distinct values available for filter dropdowns.

type FilterRule added in v0.2.21

type FilterRule struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Field     string    `json:"field"`    // "remote_ip", "route", "status_code", "method", "user_agent", "user_id"
	Operator  string    `json:"operator"` // "equals", "contains", "starts_with", "matches_glob"
	Value     string    `json:"value"`
	Action    string    `json:"action"` // "suppress", "always_promote", "tag", "short_ttl"
	Enabled   bool      `json:"enabled"`
	CreatedAt time.Time `json:"created_at"`
}

FilterRule represents a runtime filter rule that can suppress, promote, tag, or set a short TTL on traces based on request metadata.

type Handler

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

Handler is a slog.Handler that captures log records into a per-request Buffer when the record is associated with a request ID.

func NewHandler

func NewHandler(inner slog.Handler, opts ...HandlerOption) *Handler

NewHandler wraps an existing slog.Handler so that every record with a request_id attribute is also buffered per-request for promote-on-error. Optional HandlerOption values can configure buffer limits and other settings.

Example
package main

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

	"github.com/catgoose/promolog"
)

func main() {
	// Wrap any slog.Handler to capture log records per-request.
	inner := slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug})
	handler := promolog.NewHandler(inner)
	logger := slog.New(handler)

	// Attach a request ID and buffer to the context.
	ctx := context.WithValue(context.Background(), promolog.RequestIDKey, "req-001")
	ctx = promolog.NewBufferContext(ctx)

	// Log normally; records are captured in the per-request buffer.
	logger.InfoContext(ctx, "handling request", "path", "/api/users")
	logger.DebugContext(ctx, "loaded 42 rows")

	buf := promolog.GetBuffer(ctx)
	fmt.Println(len(buf.Entries()))
}
Output:
2

func (*Handler) BufferLimit added in v0.2.20

func (h *Handler) BufferLimit() int

BufferLimit returns the buffer entry limit configured on this Handler. A value of 0 means unlimited.

func (*Handler) Enabled

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

func (*Handler) Handle

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

func (*Handler) WithAttrs

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

func (*Handler) WithGroup

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

type HandlerOption added in v0.2.20

type HandlerOption func(*Handler)

HandlerOption configures optional Handler behaviour.

func WithBufferLimit added in v0.2.20

func WithBufferLimit(n int) HandlerOption

WithBufferLimit sets a cap on the number of log entries the per-request Buffer will retain. When the limit is exceeded the buffer keeps the first and last entries and inserts a synthetic entry noting how many middle entries were elided. A limit of 0 (the default) means unlimited.

type PromotionPolicy added in v0.2.21

type PromotionPolicy struct {
	// Name is a human-readable label for debugging / logging.
	Name string

	// Predicate returns true when the request should be promoted.
	Predicate func(r *http.Request, statusCode int) bool
}

PromotionPolicy decides whether a completed request's buffer should be promoted to the store. Policies are evaluated by AutoPromoteMiddleware after the downstream handler returns.

func LatencyPolicy added in v0.2.21

func LatencyPolicy(threshold time.Duration) PromotionPolicy

LatencyPolicy returns a PromotionPolicy that promotes any request whose duration exceeds threshold. It reads the request start time stored in the context by CorrelationMiddleware; if no start time is present the predicate returns false.

func RoutePolicy added in v0.2.21

func RoutePolicy(pattern string, predicate func(statusCode int) bool) PromotionPolicy

RoutePolicy returns a PromotionPolicy that promotes when the request path matches pattern (using path.Match) and predicate reports true for the response status code. pattern follows the same rules as path.Match (e.g. "/api/*").

func SamplePolicy added in v0.2.21

func SamplePolicy(rate float64, rng *rand.Rand) PromotionPolicy

SamplePolicy returns a PromotionPolicy that promotes a random fraction of requests. rate must be in the closed interval [0, 1] (e.g. 0.01 = 1%). Values outside that range, or NaN, panic at construction time because they indicate a configuration bug that should fail loudly rather than silently misbehave. An optional *rand.Rand source can be provided for deterministic testing; when nil a default source is used.

func StatusPolicy added in v0.2.21

func StatusPolicy(minCode int) PromotionPolicy

StatusPolicy returns a PromotionPolicy that promotes when the response status code is >= minCode. A typical default is 500 (server errors only).

type RetentionEngine added in v0.2.21

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

RetentionEngine evaluates retention rules against trace metadata. It holds rules in memory for fast evaluation. A nil or zero-value RetentionEngine never matches (the default TTL applies).

func NewRetentionEngine added in v0.2.21

func NewRetentionEngine(rules []RetentionRule) *RetentionEngine

NewRetentionEngine creates a RetentionEngine loaded with the given rules. Only enabled rules are retained.

func (*RetentionEngine) HasRules added in v0.2.21

func (re *RetentionEngine) HasRules() bool

HasRules reports whether the engine has any enabled rules loaded.

func (*RetentionEngine) Match added in v0.2.21

func (re *RetentionEngine) Match(fields map[string]string) (RetentionRule, bool)

Match evaluates all loaded retention rules against the provided field values. It returns the matching rule with the shortest TTL (most aggressive retention). If no rule matches, matched is false.

type RetentionRule added in v0.2.21

type RetentionRule struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Field     string    `json:"field"`    // "route", "status_code", "method", etc.
	Operator  string    `json:"operator"` // "equals", "contains", "starts_with", "matches_glob"
	Value     string    `json:"value"`
	TTLHours  int       `json:"ttl_hours"` // retention period in hours
	Enabled   bool      `json:"enabled"`
	CreatedAt time.Time `json:"created_at"`
}

RetentionRule defines a per-route/status retention policy. Traces matching the rule are retained for the rule's TTL instead of the global default.

type RuleAction added in v0.2.21

type RuleAction struct {
	// Action is the action to take: "suppress", "always_promote", "tag", "short_ttl".
	Action string

	// Rule is the filter rule that matched.
	Rule FilterRule
}

RuleAction is the result returned when a filter rule matches.

type RuleEngine added in v0.2.21

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

RuleEngine evaluates filter rules against request metadata. It holds rules in memory for fast evaluation. A nil or zero-value RuleEngine never matches (preserving existing behavior when no rules are configured).

func NewRuleEngine added in v0.2.21

func NewRuleEngine(rules []FilterRule) *RuleEngine

NewRuleEngine creates a RuleEngine loaded with the given rules. Only enabled rules are retained.

func (*RuleEngine) Match added in v0.2.21

func (re *RuleEngine) Match(fields map[string]string) (RuleAction, bool)

Match evaluates all loaded rules against the provided field values. It returns the first matching rule's action. If no rule matches, matched is false.

type Storer

type Storer interface {
	InitSchema() error
	SetOnPromote(fn func(TraceSummary))
	Promote(ctx context.Context, trace Trace) error
	PromoteAt(ctx context.Context, trace Trace, createdAt time.Time) error
	Get(ctx context.Context, requestID string) (*Trace, error)
	ListTraces(ctx context.Context, f TraceFilter) ([]TraceSummary, int, error)
	AvailableFilters(ctx context.Context, f TraceFilter) (FilterOptions, error)
	DeleteTrace(ctx context.Context, requestID string) error
	StartCleanup(ctx context.Context, ttl time.Duration, interval time.Duration)
	CreateRule(ctx context.Context, rule FilterRule) (FilterRule, error)
	ListRules(ctx context.Context) ([]FilterRule, error)
	UpdateRule(ctx context.Context, rule FilterRule) error
	DeleteRule(ctx context.Context, id int) error
	CreateRetentionRule(ctx context.Context, rule RetentionRule) (RetentionRule, error)
	ListRetentionRules(ctx context.Context) ([]RetentionRule, error)
	UpdateRetentionRule(ctx context.Context, rule RetentionRule) error
	DeleteRetentionRule(ctx context.Context, id int) error
	Aggregate(ctx context.Context, f AggregateFilter) ([]AggregateResult, error)
}

Storer defines the interface for trace persistence. Useful for mocking in tests.

type Trace added in v0.2.20

type Trace struct {
	RequestID       string
	ParentRequestID string `json:"parent_request_id,omitempty"`
	ErrorChain      string
	StatusCode      int
	Route           string
	Method          string
	UserAgent       string
	RemoteIP        string
	UserID          string
	Tags            map[string]string
	Entries         []Entry
	RequestBody     string `json:"request_body,omitempty"`
	ResponseBody    string `json:"response_body,omitempty"`
	CreatedAt       time.Time
}

Trace contains all the information captured when a request is promoted. ErrorChain is optional and may be empty for non-error promotions.

type TraceFilter

type TraceFilter struct {
	Q       string
	Status  string
	Method  string
	Tags    map[string]string // filter traces by tag key-value pairs
	Sort    string
	Dir     string
	Page    int
	PerPage int
}

TraceFilter holds all filter parameters for ListTraces.

type TraceSummary

type TraceSummary struct {
	RequestID       string
	ParentRequestID string `json:"parent_request_id,omitempty"`
	ErrorChain      string
	StatusCode      int
	Route           string
	Method          string
	RemoteIP        string
	UserID          string
	Tags            map[string]string
	CreatedAt       time.Time
}

TraceSummary is a lightweight row for list views (no log entries).

Directories

Path Synopsis
export
json
Package jsonexport provides a promolog.Exporter that writes traces as JSON lines to an io.Writer.
Package jsonexport provides a promolog.Exporter that writes traces as JSON lines to an io.Writer.
webhook
Package webhook provides a promolog.Exporter that POSTs traces as JSON to a configurable HTTP endpoint.
Package webhook provides a promolog.Exporter that POSTs traces as JSON to a configurable HTTP endpoint.
sqlite module

Jump to

Keyboard shortcuts

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