solislog is a small template-based contextual logger for Go.
It focuses on readable console output, contextual fields, simple templates, optional colors, optional JSON output, and an API that stays close to normal Go patterns.
The goal is not to compete with zap, zerolog, or slog on performance. The goal is to keep logging simple, readable, and pleasant to use in small and medium Go projects.
- Multiple handlers per logger
- Per-handler log level filtering
- Per-handler templates
- Colorized text output with tags like
<red>...</red>and<level>...</level> - Built-in template fields:
{time},{level},{message},{extra} - Caller metadata fields:
{file},{path},{line},{function},{caller} - Custom contextual fields through
{extra[key]} - Optional JSON output mode
BeforeHookandAfterHookfor per-handler record processingErrorHandlerfor write errorsBind(...)for creating child loggers with merged extra fieldsContextualize(...)andFromContext(...)for passing loggers throughcontext.Context- Simple log methods:
Debug,Info,Warning,Error,Fatal - Safe concurrent use by multiple goroutines
go get github.com/DasKaroWow/solislogpackage main
import (
"os"
"github.com/DasKaroWow/solislog"
)
func main() {
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, nil),
)
logger.Info("hello from solislog")
}Passing nil handler options uses the default template:
{time} | {level} | {message}\n
Example output:
2026-05-05T15:30:00+03:00 | INFO | hello from solislog
Templates support ANSI color tags:
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
"env": "dev",
},
solislog.NewHandler(os.Stdout, solislog.DebugLevel, &solislog.HandlerOptions{
Template: "<gray>{time}</gray> | <level>{level}</level> | service={extra[service]} env={extra[env]} | {message}\n",
}),
)
logger.Debug("debug message")
logger.Info("server started")
logger.Warning("slow request")
logger.Error("request failed")Supported colors:
<black>...</black>, <red>...</red>, <green>...</green>, <yellow>...</yellow>, <blue>...</blue>, <magenta>...</magenta>, <cyan>...</cyan>, <white>...</white>, <gray>...</gray>
Special color tag: <level>...</level>
<level> chooses a color based on the record level:
- DEBUG → gray
- INFO → cyan
- WARNING → yellow
- ERROR → red
- FATAL → magenta
Example: <level>{level}</level> | {message}
Built-in fields:
{time}{level}{message}{extra}{file}{path}{line}{function}{caller}
Extra fields:
{extra[source]}{extra[id]}{extra[path]}
Template examples:
{time} | {level} | {message}
{caller} | {level} | {message}
{time} | <level>{level}</level> | {message}
<gray>{time}</gray> | <gray>{caller}</gray> | <level>{level}</level> | source={extra[source]} | {message}
{level} | {message} | extra={extra}
Escaping is done with \:
\<red\> renders literal <red>
\{level\} renders literal {level}
Invalid templates panic during handler creation. This includes unknown placeholders, unknown colors, empty placeholders, empty extra keys, unclosed placeholders, unclosed color tags, and mismatched color tags.
solislog can render metadata about the source location where the log call was made.
Enable it by setting WithCaller: true in HandlerOptions.
{file}base file name, for examplemain.go{path}full source file path{line}source line number{function}full Go function name{caller}compact source location in the formfile:line
Example:
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<gray>{caller}</gray> | <level>{level}</level> | {message}\n",
WithCaller: true,
}),
)
logger.Info("server started")Example output:
main.go:14 | INFO | server started
Caller metadata also works in JSON mode:
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
JSON: true,
WithCaller: true,
Template: "{level} {message} {caller} {file} {line} {function}",
}),
)Extra fields are stored as:
type Extra map[string]stringIndividual extra fields can be rendered with {extra[key]}:
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
"env": "dev",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<level>{level}</level> | service={extra[service]} env={extra[env]} | {message}\n",
}),
)
logger.Info("server started")The full extra map can be rendered with {extra}:
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
"env": "dev",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{level} | {message} | extra={extra}\n",
}),
)
logger.Info("hello")Example output:
INFO | hello | extra={"env":"dev","service":"api"}
Use Bind(...) to create a child logger with additional or overridden extra fields.
base := solislog.NewLogger(
solislog.Extra{
"service": "api",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<level>{level}</level> | service={extra[service]} request_id={extra[request_id]} | {message}\n",
}),
)
requestLogger := base.Bind(solislog.Extra{
"request_id": "req-123",
})
requestLogger.Info("request received")
base.Info("base logger still has no request_id")Bind(...) does not copy or replace handlers. The child logger uses the same shared core and only changes the attached extra fields.
If extra is empty (nil or zero length), the same logger instance is returned. If a key already exists, the bound value overrides it for the child logger only.
Contextualize(...) creates a bound logger and stores it in context.Context.
This is useful at request, update, job, or operation boundaries.
package main
import (
"context"
"os"
"github.com/DasKaroWow/solislog"
)
func main() {
base := solislog.NewLogger(
solislog.Extra{
"service": "api",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<level>{level}</level> | service={extra[service]} request_id={extra[request_id]} user_id={extra[user_id]} | {message}\n",
}),
)
requestLogger := base.Bind(solislog.Extra{
"request_id": "req-123",
})
ctx := context.Background()
ctx = requestLogger.Contextualize(ctx, solislog.Extra{
"user_id": "42",
})
handleRequest(ctx)
}
func handleRequest(ctx context.Context) {
logger, ok := solislog.FromContext(ctx)
if !ok {
return
}
logger.Info("request received")
processRequest(ctx)
}
func processRequest(ctx context.Context) {
logger, ok := solislog.FromContext(ctx)
if !ok {
return
}
logger.Info("processing request")
}Set HandlerOptions.JSON to true to render records as JSON.
In JSON mode, the template is used as a field list. Plain text is ignored. Only placeholders become JSON fields.
loc, err := time.LoadLocation("Europe/Helsinki")
if err != nil {
panic(err)
}
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
"env": "dev",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
JSON: true,
TimeFormat: time.RFC3339,
Location: loc,
Template: "{time} {level} {message} {extra[service]} {extra[env]} {extra}",
}),
)
logger.Info("json message")Example output:
{"time":"2026-05-05T15:30:00+03:00","level":"INFO","message":"json message","service":"api","env":"dev","extra":{"env":"dev","service":"api"}}JSON field behavior:
{time}→"time"{level}→"level"{message}→"message"{file}→"file"{path}→"path"{line}→"line"{function}→"function"{caller}→"caller"{extra}→ full extra object{extra[id]}→ flat field named"id"
For example:
Template: "{level} {extra[id]} {extra}"
renders fields like:
{"level":"INFO","id":"123","extra":{"id":"123"}}Color tags are ignored in JSON mode:
<red>{level}</red> <level>{message}</level>
is equivalent to:
{level} {message}
for JSON output.
Handlers can define hooks for custom per-handler processing.
BeforeHook runs before the record is rendered. It receives a mutable *solislog.Record, so it can change the message or add extra fields.
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<gray>{caller}</gray> | <level>{level}</level> | service={extra[service]} hook={extra[hook]} | {message}\n",
WithCaller: true,
BeforeHook: func(record *solislog.Record) {
record.Message = strings.ToUpper(record.Message)
record.Extra["hook"] = "before"
},
}),
)
logger.Info("hook changed this message")AfterHook runs after rendering and receives both the record and the rendered log line.
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{level} | {message}\n",
AfterHook: func(record *solislog.Record, msg []byte, successful bool) {
// Count metrics, inspect the final line, or mirror it elsewhere.
_ = msg
_ = successful
},
}),
)When a logger has multiple handlers, each handler gets its own cloned record before running BeforeHook. A hook on one handler does not mutate the record used by another handler.
AfterHook and ErrorHandler callbacks run after the logger unlocks its shared core, so hooks can safely log again if needed.
ErrorHandler can be used to observe write errors from a handler's io.Writer.
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{level} | {message}\n",
ErrorHandler: func(record *solislog.Record, msg []byte, err error) {
// Handle or report the write error.
_ = record
_ = msg
_ = err
},
}),
)If ErrorHandler is nil, write errors are ignored.
A single logger can write the same record through multiple handlers. Each handler has its own writer, level, template, time settings, and output mode.
logger := solislog.NewLogger(
solislog.Extra{
"service": "api",
},
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "<level>{level}</level> | {message}\n",
}),
solislog.NewHandler(os.Stdout, solislog.ErrorLevel, &solislog.HandlerOptions{
Template: "<red>{level}</red> | service={extra[service]} | {message}\n",
}),
)
logger.Info("server started")
logger.Error("request failed")The first handler receives INFO and above. The second handler receives only ERROR and above.
Each handler can configure its own time format and location.
loc, err := time.LoadLocation("Europe/Helsinki")
if err != nil {
panic(err)
}
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{time} | {level} | {message}\n",
TimeFormat: time.DateTime,
Location: loc,
}),
)
logger.Info("hello")TimeFormat uses Go's standard time layout system.
Defaults:
Template = "{time} | {level} | {message}\n"
TimeFormat = time.RFC3339
Location = time.Local
JSON = false
WithCaller = false
Current levels:
solislog.DebugLevelsolislog.InfoLevelsolislog.WarningLevelsolislog.ErrorLevelsolislog.FatalLevel
A handler writes records whose level is equal to or higher than the handler's configured level.
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.WarningLevel, &solislog.HandlerOptions{
Template: "<level>{level}</level> | {message}\n",
}),
)
logger.Info("ignored")
logger.Warning("written")
logger.Error("written")Fatal(...) logs with FatalLevel and then exits the process with status code 1.
Logger methods are safe to call from multiple goroutines.
A base logger and all loggers created from it with Bind(...) share the same core, so their writes are serialized through that shared core.
logger := solislog.NewLogger(
nil,
solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{level} | {message}\n",
}),
)
go logger.Info("from goroutine 1")
go logger.Info("from goroutine 2")This guarantee applies to loggers that share the same solislog core.
If the same raw io.Writer is manually shared between completely separate logger instances, synchronization of that shared writer is still the caller's responsibility.
NewHandler requires an output writer and a level. Everything else is configured through HandlerOptions.
type HandlerOptions struct {
Template string
TimeFormat string
Location *time.Location
JSON bool
WithCaller bool
ReadOnly bool
ErrorHandler ErrorHandlerFunc
BeforeHook BeforeHookFunc
AfterHook AfterHookFunc
}A handler accepts any io.Writer, so file logging, buffers, custom writers, and rotation wrappers can be provided outside of solislog.
handler := solislog.NewHandler(os.Stdout, solislog.InfoLevel, &solislog.HandlerOptions{
Template: "{time} | <level>{level}</level> | {message}\n",
TimeFormat: time.RFC3339,
Location: time.Local,
JSON: false,
})| Benchmark | Iterations | Time/op | Memory/op | Allocations/op |
|---|---|---|---|---|
LoggerInfoText |
6 279 595 | 202.8 ns | 144 B | 2 |
LoggerInfoWithExtra |
4 314 960 | 269.7 ns | 160 B | 2 |
LoggerInfoJSON |
417 164 | 2 818 ns | 921 B | 32 |
LoggerInfoFilteredOut |
616 645 734 | 1.907 ns | 0 B | 0 |
LoggerInfoParallel |
15 734 155 | 76.94 ns | 144 B | 2 |
BoundLoggerInfo |
4 775 455 | 246.7 ns | 160 B | 2 |
LoggerInfoMultipleHandlers |
1 248 891 | 954.8 ns | 296 B | 11 |
LoggerInfoLockedWriter |
6 340 326 | 187.0 ns | 144 B | 2 |
To regenerate the output yourself, run:
go test -benchmem -bench . ./...solislog intentionally keeps the core small.
Not included in the core package:
- file rotation helpers
- framework-specific middleware
- async logging with queues or workers
- complex structured field types
- advanced template formatting or alignment
File rotation can be added through any custom io.Writer.
Framework integration can be built on top of Bind(...) and Contextualize(...).
MIT License.