Skip to content
happytoolinPublic

About

unolog emits one structured, canonical event per request for Go services. Formerly happycontext.

Topics

Resources

Contributing

Security policy

Stars

34 stars

Watchers

0 watching

Forks

Latest commit

 

History

173 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

unolog

unolog banner

CI Release Go Reference Go Version License: MIT

Most application logs are high-volume but low-context. unolog helps Go services emit one structured, canonical event per request, so debugging and analysis start from a complete record instead of scattered lines.

Formerly happycontext. Pre-1.0 releases remain available under the old github.com/happytoolin/happycontext module path; this repository is their renamed continuation. See MIGRATION.md.

unolog log stream demo

Why unolog?

  • Cleaner logs with one canonical event per request
  • Consistent fields across handlers, middleware, and frameworks
  • Built-in sampling for healthy traffic
  • Error and panic events are always preserved
  • Works with slog, zap, and zerolog
  • Integrates with net/http, gin, echo, fiber, fiber v3, and worker jobs

Design principle:

  • Prefer one context-rich request event over many fragmented log lines. unolog before and after

Install

go get github.com/happytoolin/unolog
go get github.com/happytoolin/unolog/adapter/slog
go get github.com/happytoolin/unolog/integration/std

Install only the adapter and integration packages you use. The modules need Go 1.25 or later, except integration/gin, integration/echo, and integration/fiberv3, which need Go 1.26 because their dependencies do.

Agent Skill

Install the repository's unolog skill in a supported coding agent:

npx skills add happytoolin/unolog --skill unolog

Then ask the agent to use $unolog when it adds or reviews request and job logging in a Go service.

Quick Start (net/http + slog)

Compile the runtime once, wrap the handler, annotate with unolog.Add:

package main

import (
	"errors"
	"log/slog"
	"net/http"
	"os"

	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	"github.com/happytoolin/unolog/integration/std"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)

	rt := unolog.MustCompile(unolog.Config{
		Sink:         sink,
		SamplingRate: 1.0,
	})
	mw := std.Middleware(rt)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
		unolog.Add(r.Context(), "user_id", "u_8472", "feature", "checkout")
		if r.URL.Query().Get("fail") == "1" {
			unolog.SetMessage(r.Context(), "checkout_failed")
			unolog.Error(r.Context(), errors.New("checkout failed"))
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		}
		unolog.SetMessage(r.Context(), "checkout_succeeded")
		w.WriteHeader(http.StatusOK)
	})

	_ = http.ListenAndServe(":8080", mw(mux))
}

Other quick starts:

  • net/http + zap and net/http + zerolog are in ## More Examples
  • gin, echo, fiber v2, and fiber v3 (with slog) are in ## More Examples
  • Runnable reference apps are in cmd/examples
  • Zero-dependency output: unolog.NewJSONSink(os.Stdout) needs no logger at all

Quick Start (Background Job)

func runImport(ctx context.Context, rt *unolog.Runtime) (err error) {
	op := unolog.Start(ctx, rt, unolog.OperationStart{
		Domain:      unolog.DomainJob,
		Name:        "import",
		ID:          "job_8472",
		Attempt:     2,
		MaxAttempts: 3,
	})
	defer op.End(&err) // captures errors AND panics; re-panics

	unolog.Add(op.Context(), "rows", 42, "source", "queue")
	return doImport(ctx)
}

One event, always: header fields, handler fields in attach order, and completion fields — deterministic by construction. Failures are never sampled away.

Configuration

Compile once at startup; *unolog.Runtime is immutable and shared by all requests. Invalid configuration is a construction-time error (unolog.ErrInvalidRate, unolog.ErrInvalidLevel, unolog.ErrInvalidOutcome) — use unolog.Compile for config from files, unolog.MustCompile for literals.

unolog.Config gives you the core controls:

  • Sink: destination logger adapter (required to emit events)
  • SamplingRate: 0 drops healthy events, 1 keeps all healthy events
  • LevelSamplingRates: optional level-specific sampling overrides
  • Sampler: optional custom sampling function (full control)
  • OperationPolicies: optional per-domain level/sampling policy for all lifecycle domains, including HTTP and background operations; domain sampling overrides generic level/default sampling
  • Precedence when both are set: a domain policy's SamplingRate overrides LevelSamplingRates for that domain (v0 behavior); LevelSamplingRates overrides the global SamplingRate
  • Message: final log message (defaults to unolog.DefaultMessage for HTTP and unolog.DefaultOperationMessage for non-HTTP)
  • MaxFields: fields one event can hold (default unolog.DefaultMaxFields, 256; -1 removes the limit)
  • MaxValueBytes: bytes per string value (default unolog.DefaultMaxValueBytes, 32 KiB; -1 removes the limit)
  • ErrorHandler: receives contained sink and sampler panics and failed sink writes (default: stderr, at most once per second)

Notes:

  • Sampling is automatically bypassed for errors and server failures.
  • If no sink is configured, requests still run; logging is skipped.
  • Sampling behavior is consistent across all integrations (net/http, gin, echo, fiber, and fiber v3).
  • unolog.SetMessage(ctx, "...") overrides Config.Message for a single event.

Limits and failure handling

The defaults bound the two common ways an event grows, so a loop or a large value cannot make one request hold unbounded memory or write an unbounded line. They do not keep a line under every pipeline limit: an event at both defaults can encode to megabytes, and Docker's json-file log driver, for example, splits lines at 16 KiB. Set the limits to match your pipeline:

  • Field limit. MaxFields bounds the distinct keys of an event. Repeated writes to one key do not use it up: when the event is full, a write to a key the event holds replaces its value in place, and a write with a new key first removes the older writes of repeated keys. A new key that still does not fit is dropped and counted in unolog.dropped_fields. Canonical keys (the unolog.Key* constants, such as http.status, op.outcome, error, and panic, except the two unolog.* counter keys) are never dropped. Other keys with the same prefix, such as http.user_agent, are ordinary keys.
  • Value limit. MaxValueBytes bounds string values: string fields, the string members of a map[string]any field (so the structured error and panic fields), and the SetMessage text. A longer value is cut at a UTF-8 boundary and counted in unolog.truncated_values. The text of an error value added with Add is cut when the canonical line is encoded (JSONSink, AsyncJSONSink, the zerolog adapter's NewCanonical, and Record.Encoded); that cut is not counted. The slog and zap adapters and the zerolog adapter's New write the error text without the cut.
  • Not covered. Field keys, nested maps, slices, structs, other any values, and the OperationStart fields are not limited. Keep them small, or convert them to strings before you add them.

The two counter fields appear only when a limit changed the event. Treat them as a signal to fix the code or to raise the limit. A negative limit removes that limit.

Logging never crashes the request. A panicking Sink or Sampler does not escape End: unolog contains it and reports a *unolog.PanicError (the recovered value and its stack; errors.Is matches unolog.ErrSinkPanic or unolog.ErrSamplerPanic) to ErrorHandler. A panic in ErrorHandler itself is recovered, and the report goes to stderr. A sampler panic keeps the event. A sink panic makes End return false, but delivery can be partial: a sink that writes to several destinations may have written to some of them before it panicked, so do not retry the write. The application's own panic still re-panics with its original value. A failed or short write from JSONSink (or any sink that implements unolog.FallibleSink) also goes to ErrorHandler, matching unolog.ErrSinkWrite and the writer's error, and End returns false. After a short write, JSONSink and AsyncJSONSink write a newline, so the next line starts on its own line. A type that embeds a FallibleSink and overrides Write must also override WriteErr, or End calls the embedded WriteErr and skips the override. A type that embeds *unolog.JSONSink is an ordinary sink: End calls its Write.

rt, err := unolog.Compile(unolog.Config{
	Sink:          sink,
	SamplingRate:  1,
	MaxValueBytes: 8 << 10, // match an 8 KiB pipeline field limit
	ErrorHandler: func(err error) {
		internalErrors.Inc() // export as a metric instead of stderr
	},
})

Per-request Message Override

Use unolog.SetMessage when a route or handler should emit a more specific final message than the integration-wide default:

func checkoutHandler(w http.ResponseWriter, r *http.Request) {
	if err := processCheckout(r.Context()); err != nil {
		unolog.SetMessage(r.Context(), "checkout_failed")
		unolog.Error(r.Context(), err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}

	unolog.SetMessage(r.Context(), "checkout_succeeded")
	w.WriteHeader(http.StatusOK)
}

Passing an empty string leaves the event on the configured default message.

Errors are recorded as structured metadata:

{
  "error": {
    "message": "checkout failed",
    "type": "*errors.errorString"
  }
}

Sampling Customization

Per-level sampling:

mw := std.Middleware(unolog.MustCompile(unolog.Config{
	Sink:         sink,
	SamplingRate: 0.05, // default for healthy traffic
	LevelSamplingRates: map[unolog.Level]float64{
		unolog.LevelWarn:  1.0, // keep all warns
		unolog.LevelDebug: 0.01,
	},
}))

Custom sampler (route/user/latency rules):

mw := std.Middleware(unolog.MustCompile(unolog.Config{
	Sink: sink,
	Sampler: func(in unolog.SampleInput) bool {
		// Always keep failures and slow requests.
		if in.HasError || in.StatusCode >= 500 {
			return true
		}
		if in.Duration > 2*time.Second {
			return true
		}
		// Keep checkout requests.
		if in.Path == "/api/checkout" {
			return true
		}
		// Keep enterprise requests based on event fields.
		tier, _ := in.Lookup("user_tier")
		return tier == "enterprise"
	},
}))

unolog.Add accepts one or more key/value pairs: unolog.Add(ctx, "k1", v1, "k2", v2, "k3", v3).

Add boxes each value into an interface{}, which allocates for most dynamic values. On hot paths, use typed fields instead: AddFields does not allocate.

unolog.AddFields(ctx,
	unolog.String("user_id", userID),
	unolog.Int("items", len(cart.Items)),
	unolog.Duration("db_time", dbTime),
)

The constructors are String, Int, Int64, Uint64, Float64, Bool, Time, Duration, and Any (the Add type mapping). Both forms produce the same wire output.

unolog.SampleInput.Method, Path, and StatusCode are HTTP compatibility fields. For non-HTTP operations use Domain, Operation, Outcome, and Code.

Built-in sampler chain:

mw := std.Middleware(unolog.MustCompile(unolog.Config{
	Sink: sink,
	Sampler: unolog.ChainSampler(
		unolog.RateSampler(0.05),        // base sampler
		unolog.KeepErrors(),             // always keep errors
		unolog.KeepPathPrefix("/admin"), // always keep admin paths
		unolog.KeepSlowerThan(500*time.Millisecond),
	),
}))

Sampler building blocks:

  • unolog.ChainSampler(base, middlewares...): composes one final Sampler from middleware rules.
  • unolog.AlwaysSampler(): base sampler that keeps every event.
  • unolog.NeverSampler(): base sampler that drops every event.
  • unolog.RateSampler(rate): base probabilistic sampler (0 drops all, 1 keeps all).
  • unolog.KeepErrors(): middleware that keeps errored requests (HasError or 5xx).
  • unolog.KeepPathPrefix("/checkout", "/admin"): middleware that keeps matching path prefixes.
  • unolog.KeepSlowerThan(minDuration): middleware that keeps requests at/above a duration threshold.

Generic Operation Lifecycle API

For non-HTTP flows, use unolog.Start with the compiled runtime:

func runJob(ctx context.Context, rt *unolog.Runtime) (err error) {
	op := unolog.Start(ctx, rt, unolog.OperationStart{
		Domain: unolog.DomainJob,
		Name:   "invoice.reconcile",
		ID:     "job_1001",
		Source: "nightly",
	})
	defer op.End(&err) // direct defer: captures errors and panics

	unolog.Add(op.Context(), "account_id", "acct_42")
	return nil
}

op.End(&err) is the only completion path: one-shot, returning whether the event was emitted. The worker integration wraps this idiom for queue consumers.

Integrations

  • integration/std (net/http)
  • integration/gin
  • integration/echo
  • integration/fiber (Fiber v2)
  • integration/fiberv3 (Fiber v3)
  • integration/worker (background jobs/non-HTTP operations)

Logger Adapters

  • adapter/slog
  • adapter/zap
  • adapter/zerolog

Adapters expose New. The zerolog adapter also exposes NewWithLoggerTimestamp for loggers configured with .With().Timestamp(), because zerolog does not expose hook inspection. NewCanonical writes the canonical line directly when zerolog context, hooks, sampling, and rendering customization are not needed. Fields arrive in insertion order, deterministically, as typed constructors.

A user field whose key the logger writes itself is renamed to fields.<key>, as the canonical line renames message, time, and level, so no output has two members with one key. The slog adapter renames time, level, and msg. It does not rename source: the handler writes its own source only with AddSource, so do not use a source field with AddSource. The zap adapter's New renames the keys of zap.NewProductionEncoderConfig() (ts, level, logger, caller, msg, stacktrace); for an encoder with other keys, use uzap.NewWithEncoderConfig(logger, encoderConfig).

First-party JSON sink (no logger dependency)

unolog.NewJSONSink(w io.Writer) emits the same canonical event shape as the zerolog adapter — lowercase level, RFC3339 time, your fields, message last — as one JSON line per event, with zero dependencies beyond the standard library (the module's only non-stdlib require is go.uber.org/goleak — a test-only dependency used by the package's goroutine-leak check, never linked into consumers):

sink := unolog.NewJSONSink(os.Stdout)

The wire format matches zerolog.New(w).With().Timestamp().Logger() through adapter/zerolog, so existing pipelines ingest it unchanged. Field order is insertion order — deterministic by construction, identical across every sink. A failed or short write goes to Config.ErrorHandler, and End returns false.

Asynchronous JSON sink

JSONSink writes on the request goroutine, so a slow writer (a pipe, a network connection, a busy disk) makes every request wait. unolog.NewAsyncJSONSink writes the same lines from one background goroutine: the request only copies its line into a buffer.

sink := unolog.NewAsyncJSONSink(os.Stdout, unolog.AsyncJSONSinkConfig{
	BufferBytes: 1 << 20, // the default; memory use is at most twice this
})
defer sink.Close() // writes the remaining lines and stops the goroutine

The sink never blocks a request. When the buffer is full, it drops the line, counts it in sink.Dropped(), and reports the drops to its ErrorHandler (unolog.ErrSinkFull). Write errors and writer panics go to the same handler. The handler runs on the writer goroutine, so it must not call Close, or Flush with a context that is never done: both wait for that goroutine. Flush(ctx) waits until the buffered lines are written. Use JSONSink instead when every line must be written.

Performance

Per-line loggers charge you per line: format, allocate, write — on every request, whether the line is kept or not. unolog has a different cost shape. Fields accumulate in a typed write-ahead log while the request runs; one record is materialized at End, sampled once, and handed to every sink. Nothing is formatted per line, and a sampled-out request never builds a record at all.

unolog benchmarks

Realistic route workload

The route benchmark runs the same checkout flow across net/http, Gin, Echo, Fiber v2, and Fiber v3 with slog, Zap, and Zerolog. The workload uses 95% successful requests and 5% failed requests.

95/5 request mix unolog vs direct logging
Request time 32% faster
OS log writes 1.00 vs 5.95 per request
Log output 50% fewer bytes
Heap bytes 39% more
Allocations 32% more

These values are the geometric mean across all 15 router and logger pairs. They use the median of 1,000 samples per case on an Apple M4 with Go 1.27. Each sample has a 10 ms measurement window. Each JSON event is encoded and written to the OS null device, so the benchmark includes system call cost but excludes disk and network delay. Error-only routes were 2.1% slower overall because structured error data costs more to build. See the BenchmarkRealisticRoutes source for the full matrix.

Logging the same 12 fields to a discarded output — each logger alone, and the same logger end to end through unolog (Apple M4 / Go 1.27, medians of 25 runs in one session):

Logger alone + unolog added allocs
slog JSON 866 ns 1611 ns +745 ns (+86%) 1 → 8
zap JSON 667 ns 1415 ns +748 ns (+112%) 1 → 8
zerolog canonical output 202 ns 748 ns +546 ns (+270%) 0 → 3

That added slice is the entire unolog cost — Start, 12 Adds, one sampling decision, bridge, encode — and it buys a single reusable record that every sink and adapter shares. Your logger keeps doing its own work; unolog just never formats per line. With routing in the picture the shape flips: one unolog event through the std middleware costs 362 ns, less than one slog JSON line at 504 ns, and a sampled-out empty lifecycle costs 204 ns with two allocations.

Compare pairs within a session: host-logger floors drift a few percent between recordings, so re-measure both rows together rather than mixing runs.

Then the unolog paths alone (Start → Add ×12 → End → sink, no router):

Path ns/op allocs/op
core only, discard sink 400 2
first-party JSON sink, 12 fields 746 3
zerolog canonical output, 12 fields 748 3
zap adapter, 12 fields 1415 8
slog adapter, 12 fields 1611 8

What that means:

  • The core pipeline is ~0.20–0.40 µs with two allocations; everything to its right is the sink or host logger doing its own work.
  • NewCanonical preserves the measured zerolog row without private logger access. New uses zerolog's public field API, honors its customization, and costs about 0.2 µs more end to end while removing one lifecycle allocation.
  • A sampled-out empty lifecycle costs 204 ns and two allocations — less than most loggers spend formatting a single line.
  • Sampling runs before any sink work, and errors/panics bypass sampling structurally — the cheap path can never hide a failure.
  • Twelve fields ride on one typed WAL slice: Add does not box per field, the record is encoded once, and every sink and adapter reads that same record.
  • Adapter rows include the bridge and host logger allocations; they are end-to-end totals.

Run the suites yourself:

just bench           # everything
just bench-core      # lifecycle, sampler, WAL, encoder
just bench-routers   # std, gin, echo, fiber, fiberv3
just bench-adapters  # slog, zap, zerolog bridges

The head-to-head runs the logger directly in the handler and unolog through its std middleware on the same route with the same fields, both discarding output — no strawman baselines. just bench reproduces every suite.

More Examples

1. net/http + slog
package main

import (
	"log/slog"
	"net/http"
	"os"

	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	"github.com/happytoolin/unolog/integration/std"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)
	mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		unolog.Add(r.Context(), "router", "net/http")
		w.WriteHeader(http.StatusOK)
	})

	_ = http.ListenAndServe(":8101", mw(mux))
}
2. gin + slog
package main

import (
	"log/slog"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	ugin "github.com/happytoolin/unolog/integration/gin"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)

	r := gin.New()
	r.Use(ugin.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
	r.GET("/users/:id", func(c *gin.Context) {
		unolog.Add(c.Request.Context(), "router", "gin")
		c.Status(200)
	})

	_ = r.Run(":8105")
}
3. fiber v2 + slog
package main

import (
	"log/slog"
	"os"

	"github.com/gofiber/fiber/v2"
	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	ufiber "github.com/happytoolin/unolog/integration/fiber"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)

	app := fiber.New()
	app.Use(ufiber.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
	app.Get("/users/:id", func(c *fiber.Ctx) error {
		unolog.Add(c.UserContext(), "router", "fiber-v2")
		return c.SendStatus(200)
	})

	_ = app.Listen(":8107")
}
4. fiber v3 + slog
package main

import (
	"log/slog"
	"os"

	"github.com/gofiber/fiber/v3"
	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	ufiberv3 "github.com/happytoolin/unolog/integration/fiberv3"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)

	app := fiber.New()
	app.Use(ufiberv3.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
	app.Get("/users/:id", func(c fiber.Ctx) error {
		unolog.Add(c.Context(), "router", "fiber-v3")
		return c.SendStatus(200)
	})

	_ = app.Listen(":8108")
}
5. echo + slog
package main

import (
	"log/slog"
	"os"

	"github.com/happytoolin/unolog"
	uslog "github.com/happytoolin/unolog/adapter/slog"
	uecho "github.com/happytoolin/unolog/integration/echo"
	"github.com/labstack/echo/v4"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	sink := uslog.New(logger)

	e := echo.New()
	e.Use(uecho.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1})))
	e.GET("/users/:id", func(c echo.Context) error {
		unolog.Add(c.Request().Context(), "router", "echo")
		return c.NoContent(200)
	})

	_ = e.Start(":8106")
}
6. net/http + zap
package main

import (
	"net/http"

	"github.com/happytoolin/unolog"
	uzap "github.com/happytoolin/unolog/adapter/zap"
	"github.com/happytoolin/unolog/integration/std"
	"go.uber.org/zap"
)

func main() {
	logger := zap.NewExample()
	sink := uzap.New(logger)
	mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		unolog.Add(r.Context(), "example", "adapter-zap")
		w.WriteHeader(http.StatusOK)
	})

	_ = http.ListenAndServe(":8102", mw(mux))
}
7. net/http + zerolog
package main

import (
	"net/http"
	"os"

	"github.com/happytoolin/unolog"
	uzerolog "github.com/happytoolin/unolog/adapter/zerolog"
	"github.com/happytoolin/unolog/integration/std"
	"github.com/rs/zerolog"
)

func main() {
	logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
	sink := uzerolog.NewWithLoggerTimestamp(&logger)
	mw := std.Middleware(unolog.MustCompile(unolog.Config{Sink: sink, SamplingRate: 1}))

	mux := http.NewServeMux()
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		unolog.Add(r.Context(), "example", "adapter-zerolog")
		w.WriteHeader(http.StatusOK)
	})

	_ = http.ListenAndServe(":8103", mw(mux))
}

Runnable commands are also available in cmd/examples:

cd cmd/examples
go run ./adapter-slog
go run ./adapter-zap
go run ./adapter-zerolog
go run ./router-std
go run ./router-gin
go run ./router-echo
go run ./router-fiber
go run ./router-fiberv3
go run ./sampling-inbuilt
go run ./sampling-custom
go run ./worker-job

Release Process

  • CI: .github/workflows/ci.yml
  • Release automation: .github/workflows/release.yml
  • Go proxy sync: .github/workflows/go-proxy-sync.yml
  • Root module releases must be tagged as vX.Y.Z.
  • Nested Go modules must be tagged as <subdir>/vX.Y.Z so go list -m -versions can discover them.
  • To backfill historical tags created with the old unolog-vX.Y.Z format, run ./scripts/backfill-go-tags.sh and push the generated tags.

Published nested modules:

  • adapter/slog
  • adapter/zap
  • adapter/zerolog
  • integration/echo
  • integration/fiber
  • integration/fiberv3
  • integration/gin
  • integration/std
  • integration/worker

Migrating from v0.x

Coming from a 0.x release? MIGRATION.md maps every removed or changed symbol, the outcome-precedence change, and the wire format differences.

References

Contributing and Security

License

MIT

About

unolog emits one structured, canonical event per request for Go services. Formerly happycontext.

Topics

Resources

Contributing

Security policy

Stars

34 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages