seilog

package module
v0.0.3 Latest Latest
Warning

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

Go to latest
Published: Mar 12, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

README

seilog

Go Reference

Structured logging with per-logger runtime level control, built on log/slog.

seilog was created to provide a uniform logging configuration experience across artifacts produced by Sei. Nothing in the library is Sei-specific — it is a general-purpose slog extension that any Go project can use.

Why seilog?

The standard library's slog gives you structured logging and pluggable handlers, but it lacks two things that matter in production:

  1. Per-logger level control. slog has a single global level. When you're debugging a database issue in production, you want DEBUG on your db package without drowning in noise from every other subsystem.

  2. Runtime level changes. Restarting a process to flip a log level is slow and disruptive. seilog lets you change levels on the fly — via code, an admin endpoint, or a signal handler — with immediate effect across all goroutines.

seilog adds both while staying out of your way: NewLogger returns a plain *slog.Logger. There is no wrapper type, no custom interface, and no lock-in. Your code uses the standard slog API everywhere.

Features

  • Hierarchical logger names"myapp/db/pool" mirrors your package structure and enables targeted level changes.
  • Runtime level control — change levels per logger, by glob pattern, or recursively across an entire subtree without restarting.
  • Zero-alloc hot path — the enabled-level check is a single atomic load; disabled log calls cost ~5 ns.
  • Standard *slog.Logger return type — no wrapper, no lock-in, full compatibility with the slog ecosystem.
  • Strict naming validation — enforced at creation time to prevent typos, injection, and naming inconsistencies across a large codebase.
  • Environment-variable configuration — format, output destination, and default level are set once at startup with no code changes.
  • Concurrent safety — all functions are safe for concurrent use. Level changes are atomic and visible immediately.

Quick start

package main

import (
    "log/slog"
    "github.com/sei-protocol/seilog"
)

// Create loggers at package level — they're cheap and reusable.
var log = seilog.NewLogger("myapp", "db")

func main() {
    defer seilog.Close()

    log.Info("connected", "host", "localhost", "port", 5432)

    // Turn on debug for the db subtree at runtime.
    seilog.SetLevel("myapp/db/**", slog.LevelDebug)

    log.Debug("query plan", "sql", "SELECT ...")
}

Logger naming

Logger names form a /-separated hierarchy. Use the variadic form of NewLogger to build them — each segment is validated individually:

seilog.NewLogger("myapp")               // "myapp"
seilog.NewLogger("myapp", "db")         // "myapp/db"
seilog.NewLogger("myapp", "db", "pool") // "myapp/db/pool"

Each segment must match [a-z0-9]+(-[a-z0-9]+)* (lowercase alphanumerics and hyphens). This is enforced via panic at creation time.

The lowercase-only rule is a deliberate tradeoff — what matters most is consistency. In a multi-team codebase, if one package registers "MyApp" and another uses "myapp", they silently create separate registry entries and SetLevel on one won't affect the other. Rather than trusting convention, the library enforces a single canonical form. Lowercase was chosen because it's the most common Go naming convention for identifiers in packages and paths, but any uniform rule would serve the same purpose.

The broader constraints (no whitespace, no special characters) exist for additional reasons:

Reason Detail
Glob safety Segments cannot contain *, ?, or [, so a bare name is always an exact match in SetLevel — never accidentally a pattern.
Log hygiene No whitespace, newlines, or special characters means log output stays parseable and injection-free.

Setting levels

Levels can be changed at any time without restarting the process:

// Exact match
seilog.SetLevel("myapp/db", slog.LevelDebug)

// Glob — direct children only (path.Match semantics, * doesn't cross /)
seilog.SetLevel("myapp/*", slog.LevelDebug)

// Glob — grandchildren only
seilog.SetLevel("myapp/*/*", slog.LevelWarn)

// Recursive — myapp and ALL descendants at any depth
seilog.SetLevel("myapp/**", slog.LevelDebug)

// Everything
seilog.SetLevel("*", slog.LevelWarn)

SetLevel returns the number of loggers matched, which helps catch typos:

if n := seilog.SetLevel("myap/db", slog.LevelDebug); n == 0 {
    fmt.Println("typo? no loggers matched")
}

Querying levels

lvl, ok := seilog.GetLevel("myapp/db")
if ok {
    fmt.Printf("myapp/db is at %s\n", lvl)
}

// List all registered loggers.
for _, name := range seilog.ListLoggers() {
    lvl, _ := seilog.GetLevel(name)
    fmt.Printf("  %-30s %s\n", name, lvl)
}

Environment variables

Output format, destination, and default level are configured at startup via environment variables. These are read once during package init and cannot be changed afterward.

Variable Values Default
SEI_LOG_LEVEL debug, info, warn, error info
SEI_LOG_FORMAT json, text text
SEI_LOG_OUTPUT stdout, stderr, or an absolute file path stdout
SEI_LOG_ADD_SOURCE true, false false

When SEI_LOG_OUTPUT is a file path:

  • The path must be absolute and must not contain .. components.
  • Files are opened with mode 0600 and O_APPEND for atomic POSIX writes.
  • seilog does not perform log rotation — use an external tool like logrotate.
  • Call seilog.Close() during graceful shutdown to flush and close the file descriptor.

API

func NewLogger(name string, subs ...string) *slog.Logger
func SetLevel(name string, level slog.Level) int
func GetLevel(name string) (slog.Level, bool)
func SetDefaultLevel(level slog.Level, updateExisting bool)
func ListLoggers() []string
func Close() error

Full documentation: pkg.go.dev/github.com/sei-protocol/seilog

Performance

seilog's goal is to add per-logger level control with negligible overhead compared to using slog directly. Benchmarks on Apple M2 Max (arm64), comparing seilog against stdlib slog with the same JSONHandler writing to io.Discard:

Benchmark seilog stdlib slog Overhead Allocs
Info (3 attrs, JSON) 582 ns/op 563 ns/op +3% 0
Disabled level 5.0 ns/op 5.9 ns/op −15% 0
Typed attrs (LogAttrs) 619 ns/op 607 ns/op +2% 0
Pre-bound attrs (.With) 451 ns/op 441 ns/op +2% 0
Parallel (12 goroutines) 230 ns/op 225 ns/op +2% 0
Text handler 646 ns/op 634 ns/op +2% 0

Key takeaways:

  • Zero allocations on every hot path. seilog adds no allocations beyond what slog itself performs.
  • Disabled-level calls are ~5 ns — a single atomic load short-circuits before touching the handler. This is 15% faster than stdlib because seilog's LevelVar check avoids the handler dispatch entirely.
  • Enabled-level overhead is 2–3% across all scenarios (Info, typed attrs, pre-bound attrs, text handler, parallel). This is within benchmark noise — seilog is effectively free at log time.
  • Contention under concurrent SetLevel mutations adds modest overhead (~325 ns/op) with no lock contention surprises — the RWMutex + atomic LevelVar design holds up cleanly.
go test -bench=. -benchmem ./...

Best practices

Logger creation

Create loggers as package-level variables. This is cheap (one registry lookup after the first call), keeps names consistent, and avoids passing loggers through function arguments:

// db/db.go
var log = seilog.NewLogger("myapp", "db")

// db/pool.go
var poolLog = seilog.NewLogger("myapp", "db", "pool")

Avoid creating loggers inside functions or request handlers — it adds unnecessary registry lookups on every call and makes it harder to discover which loggers exist.

Message style

Log messages should be short, static strings that describe what happened. Put variable data in attributes, not in the message itself:

// Good — message is static, data is structured and searchable.
log.Info("Query executed", "sql", query, "duration-ms", elapsed.Milliseconds(), "rows", count)

// Bad — message changes every time, impossible to group or filter.
log.Info(fmt.Sprintf("query %s took %dms and returned %d rows", query, elapsed.Milliseconds(), count))

Whether you capitalize the first letter ("Query executed") or keep it all lowercase ("query executed") doesn't matter — what matters is that your codebase picks one convention and sticks with it. Inconsistent casing makes log search and grouping harder than it needs to be.

Use past tense or noun phrases and skip trailing punctuation: "Connection opened", "Cache miss", "Block committed". Avoid generic messages like "error" or "done" — they're useless when scanning logs.

Attribute keys

Use lowercase kebab-case for consistency: "request-id", "block-height", "duration-ms". Include units in the key name when the value is numeric ("duration-ms", "size-bytes"). Prefer slog.String, slog.Int, and slog.Any over untyped key-value pairs when performance matters — the typed API avoids any boxing.

Level selection
Level Use for
Debug Internal state useful only when investigating a specific subsystem. High volume, off by default.
Info Normal operational events worth recording: startup, shutdown, configuration loaded, connections established.
Warn Unexpected but recoverable situations: retries, fallbacks, deprecated code paths hit.
Error Failures that need attention: unhandled errors, invariant violations, data loss.

If you find yourself setting Debug on a logger and leaving it on permanently, the messages are probably Info. If Warn messages never lead to action, they're noise — demote them to Debug or remove them.

Error logging

Log errors at the point where you handle them, not at every level of the call stack. If you return an error, don't also log it — the caller will decide:

// Good — log once at the handling site.
if err := db.Exec(ctx, query); err != nil {
    log.Error("Query failed", "sql", query, "err", err)
    return fmt.Errorf("execute query: %w", err)
}

// Bad — logs the same error at every layer.

Use "err" as the conventional key for error values.

Contextual attributes

Use .With() to attach request-scoped data once rather than repeating it on every log call:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    reqLog := log.With("request-id", r.Header.Get("X-Request-ID"), "method", r.Method)
    reqLog.Info("Request started", "path", r.URL.Path)
    // ... all subsequent logs carry request-id and method.
    reqLog.Info("Request completed", "status", 200)
}
Naming hierarchy

Mirror your module or package structure. A consistent convention makes SetLevel patterns predictable across teams:

myapp
myapp/db
myapp/db/pool
myapp/api
myapp/api/middleware
myapp/indexer

This way myapp/db/** always means "the database layer and everything in it" and myapp/api/* means "direct children of the API layer" — no guesswork.

Design choices

Choice Rationale
Returns *slog.Logger, not a custom type No lock-in. Callers use the standard API; seilog can be swapped out without changing application code.
Panics on invalid names Invalid logger names are programmer errors (wrong arguments at call sites). Panicking catches them immediately during development rather than masking bugs at runtime. A default or error return would silently produce untracked loggers.
Env-var configuration, not programmatic Keeps the API surface minimal. Output format and destination rarely change in code — they're deployment concerns. This avoids a builder/options API and keeps NewLogger to a single clean call.
Handler captured at creation time Avoids per-log-call overhead of reconstructing the handler chain. The tradeoff is that existing loggers won't see handler reconfiguration (format/output swaps), but runtime level changes work because they mutate a shared LevelVar atomically.
Strict naming regex Prevents a class of bugs (inconsistent casing, accidental glob injection, whitespace in log output) that are painful to debug in production across a multi-team codebase.
/** recursive match path.Match has no recursive glob. Without /**, there's no way to target an entire subtree — you'd need one SetLevel call per depth level, which is impractical.

License

Apache License v2.

Documentation

Overview

Package seilog provides structured logging with per-logger level control, built on top of log/slog.

seilog adds two things standard slog does not offer out of the box: hierarchical logger naming and the ability to change log levels at runtime without restarting the process. Every logger created through NewLogger returns a plain *slog.Logger, so callers use the standard library API they already know — there is no wrapper type and no lock-in.

Quick Start

var log = seilog.NewLogger("myapp", "db")

func main() {
	log.Info("connected", "host", "localhost")
	seilog.SetLevel("myapp/*", slog.LevelDebug) // turn on debug for direct children of myapp
	seilog.SetLevel("myapp/**", slog.LevelDebug) // turn on debug for all children of myapp
}

Logger Naming

Logger names form a hierarchy separated by "/". The recommended convention is to mirror your module or package structure so that names are globally unique, predictable, and easy to target with glob patterns:

seilog.NewLogger("myapp")               // top-level
seilog.NewLogger("myapp", "db")         // → "myapp/db"
seilog.NewLogger("myapp", "db", "pool") // → "myapp/db/pool"

Each segment must match the pattern [a-z0-9]+(-[a-z0-9]+)*. This is enforced at creation time via panic. The constraint exists for three reasons:

  1. Consistency — uniform naming across a large codebase prevents typos like "MyApp" vs "myapp" from silently creating separate loggers.
  2. Glob safety — because segments cannot contain glob meta-characters (*, ?, [), a bare name is always an exact match in SetLevel and never accidentally interpreted as a pattern.
  3. Log hygiene — disallowing whitespace, newlines, and special characters keeps log output parseable and prevents injection into structured formats.

Use the variadic form of NewLogger rather than embedding "/" directly in a segment name. The variadic form makes the hierarchy explicit and is validated per-segment.

Good: "myapp", "http-server", "myapp/db/pool" Bad: "MyApp", "my app", "", "myapp//db"

Setting and Querying Levels

Levels can be changed at runtime per logger or by pattern, and queried for diagnostics:

seilog.SetLevel("myapp/db", slog.LevelDebug)  // exact match
seilog.SetLevel("myapp/*", slog.LevelDebug)    // direct children of myapp
seilog.SetLevel("myapp/*/*", slog.LevelWarn)   // grandchildren of myapp only
seilog.SetLevel("myapp/**", slog.LevelDebug)   // myapp and ALL descendants

lvl, ok := seilog.GetLevel("myapp/db")         // query current level

Glob patterns follow path.Match semantics. Each "*" matches a single path segment and does not cross "/" boundaries:

"myapp/*"    matches "myapp/db"         but NOT "myapp/db/pool"
"myapp/*/*"  matches "myapp/db/pool"    but NOT "myapp/db"
"*/db"       matches "myapp/db"         but NOT "myapp/v2/db"

seilog extends standard glob matching with two special patterns:

  • "/**" suffix — recursive prefix match. "myapp/**" matches "myapp" itself and every logger whose name starts with "myapp/" at any depth. This is the primary way to adjust an entire subtree at once.
  • "*" alone — matches every registered logger regardless of depth.

To change the baseline level for loggers that have not yet been created, use SetDefaultLevel. To inspect all registered logger names (e.g. for an admin endpoint), use ListLoggers.

Output and Lifecycle

Output format, destination, and source-location recording are configured once at process startup through environment variables. These settings are read during package init and cannot be changed afterward; the handler is captured by each logger at creation time.

SEI_LOG_LEVEL      — Default level: debug, info, warn, error (default: info).
SEI_LOG_FORMAT     — Output format: json or text (default: text).
SEI_LOG_OUTPUT     — Destination: stdout, stderr, or an absolute file path
                     (default: stdout). File paths must not contain ".."
                     components. Files are opened with mode 0600 and
                     O_APPEND for atomic POSIX writes. The operator is
                     responsible for ensuring the path is trusted.
                     seilog does not perform log rotation — pair with an
                     external tool such as logrotate when writing to files.
SEI_LOG_ADD_SOURCE — Include source file and line in output (default: false).

When SEI_LOG_OUTPUT points to a file, call Close during graceful shutdown to flush and close the file descriptor. Close is safe to call multiple times and is a no-op for stdout and stderr. If Close is not called, the operating system will close the descriptor on process exit, but buffered data may be lost.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Close

func Close() error

Close closes the log output opened via the SEI_LOG_OUTPUT environment variable. It is a no-op when output is stdout or stderr.

Call Close during graceful shutdown to ensure the file descriptor is flushed and released. It is safe to call multiple times; only the first call performs the close. After Close returns, further log writes to the file may fail silently.

Typical usage:

func main() {
	defer seilog.Close()
	// ...
}

If Close is never called, the operating system will close the descriptor when the process exits, but any data buffered by the OS may be lost. See the package-level documentation under "Output and Lifecycle" for details on how output is configured.

func GetLevel

func GetLevel(name string) (slog.Level, bool)

GetLevel returns the current log level of a registered logger.

If a logger with the given name exists, GetLevel returns its level and true. If no logger with that name has been created via NewLogger, it returns 0 and false.

The returned level reflects the most recent change made by SetLevel, SetDefaultLevel, or the initial default — whichever was applied last to this logger.

GetLevel is intended for admin endpoints, diagnostics, and tests:

if lvl, ok := seilog.GetLevel("myapp/db"); ok {
	fmt.Printf("myapp/db is at %s\n", lvl)
}

GetLevel is safe for concurrent use.

func ListLoggers

func ListLoggers() []string

ListLoggers returns the names of all loggers registered via NewLogger. The returned slice is in no particular order.

This is useful for building admin or diagnostics endpoints that display registered loggers alongside their current levels (see GetLevel), or for verifying that a glob pattern passed to SetLevel will match the intended loggers before applying it.

ListLoggers is safe for concurrent use.

func NewLogger

func NewLogger(name string, subs ...string) *slog.Logger

NewLogger creates a named logger whose level can be changed at runtime.

The returned *slog.Logger is a standard library logger — callers use the normal slog API (Info, Debug, With, WithGroup, etc.) with no seilog-specific wrapper.

Sub-segments are joined with "/" to form a hierarchical name:

seilog.NewLogger("myapp")                // "myapp"
seilog.NewLogger("myapp", "db")          // "myapp/db"
seilog.NewLogger("myapp", "db", "pool")  // "myapp/db/pool"

Each segment must be lowercase alphanumerics and hyphens only, matching the pattern [a-z0-9]+(-[a-z0-9]+)*. This is enforced at creation time via panic because an invalid name is always a programmer error and should be caught immediately during development, not silently masked at runtime. See the package-level documentation for the rationale behind this constraint.

Use the variadic subs parameter rather than embedding "/" directly in a segment. The variadic form ensures each segment is validated individually and keeps naming consistent across a codebase.

Calling NewLogger multiple times with the same resolved name returns distinct *slog.Logger instances that share the same underlying slog.LevelVar. This means changing the level via SetLevel, SetDefaultLevel, or GetLevel affects every logger instance created with that name.

Each logger carries a "logger" attribute set to its resolved name, so log output can be filtered or searched by logger identity.

NewLogger is safe for concurrent use. It is intended to be called at package init time and the result stored in a package-level variable:

var log = seilog.NewLogger("myapp", "db")

func SetDefaultLevel

func SetDefaultLevel(level slog.Level, updateExisting bool)

SetDefaultLevel changes the baseline level applied to loggers created by future calls to NewLogger.

If updateExisting is true, every logger currently in the registry is also set to the new level. This is equivalent to calling SetLevel("*", level) followed by changing the default, and is the simplest way to uniformly adjust verbosity across the entire process.

If updateExisting is false, existing loggers retain whatever level they were last set to (via SetLevel or a previous call to SetDefaultLevel) and only newly created loggers inherit the new default. This is useful when you want to tighten the default without disrupting loggers that have been individually tuned.

SetDefaultLevel is safe for concurrent use.

func SetLevel

func SetLevel(name string, level slog.Level) int

SetLevel changes the log level at runtime for one or more loggers that have already been created by NewLogger.

The name argument can be an exact logger name, a glob pattern, or a recursive prefix:

seilog.SetLevel("myapp/db", slog.LevelDebug)   // exact match
seilog.SetLevel("myapp/*", slog.LevelDebug)     // direct children only
seilog.SetLevel("myapp/*/*", slog.LevelWarn)    // grandchildren only
seilog.SetLevel("myapp/**", slog.LevelDebug)    // myapp and all descendants

Glob patterns follow path.Match semantics. Each "*" in a glob matches a single path segment and does not cross "/" boundaries.

The "/**" suffix is a seilog-specific extension that matches the prefix logger itself and every logger whose name starts with that prefix followed by "/". For example, "myapp/**" matches "myapp", "myapp/db", and "myapp/db/pool".

As another seilog-specific extension, passing "*" alone matches every registered logger regardless of depth — this bypasses path.Match and iterates the full registry.

SetLevel only affects loggers that already exist in the registry. To also change the baseline for loggers created in the future, use SetDefaultLevel.

Returns the number of loggers whose level was changed. A return value of 0 means no registered logger matched the name or pattern — this can help detect typos. Use ListLoggers to inspect registered names and GetLevel to verify the result.

If the pattern is syntactically invalid (per path.Match), SetLevel returns 0 without modifying any logger.

SetLevel is safe for concurrent use. Level changes take effect immediately for all goroutines logging through the affected loggers.

Types

This section is empty.

Jump to

Keyboard shortcuts

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