When something goes wrong in production, the first thing you open is the logs. If they say fmt.Println("error:", err), the investigation starts from zero: no event time, no structure, no link to the request. But if the log is JSON with order_id, trace_id fields and a clear level — the cause is found in minutes.
Since version 1.21 Go ships with log/slog: a standard-library structured-logging package with no third-party dependencies.
Two formats: JSON in production, text in development
During development it's convenient to read logs as strings. In production, log collectors (Loki, ELK, Datadog) expect JSON — then they can index the fields and build complex filters without regular expressions.
We create a single logger at service startup, based on an environment variable:
// internal/platform/log/setup.go
func New(env string) *slog.Logger {
if env == "production" {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
In production each record looks like this:
{"time":"2026-06-18T10:02:00Z","level":"INFO","msg":"order_created","order_id":"ord-99","customer_id":"cust-7"}
In development — a readable line:
2026-06-18T10:02:00.123Z INFO order_created order_id=ord-99 customer_id=cust-7
The logger is created once in main and passed into components through constructors — not through a global variable and not through slog.Default().
The logger through the constructor
A common mistake is to keep the logger in a global package variable or to call slog.Default() right inside methods. The problem: the logger can't be swapped out in tests, and you can't attach persistent fields to it.
The right way: the logger is a struct field, passed through the constructor.
// internal/order/handler.go
type OrderHandler struct {
log *slog.Logger
orders OrderService
}
func NewOrderHandler(log *slog.Logger, orders OrderService) *OrderHandler {
return &OrderHandler{
log: log.With("component", "order_handler"),
orders: orders,
}
}
log.With("component", "order_handler") creates a child logger — all records from this component automatically get the field component=order_handler without repeating it in every call.
Initialization in main:
// cmd/server/main.go
logger := platform_log.New(cfg.AppEnv)
orderHandler := order.NewOrderHandler(logger, orderService)
productHandler := product.NewProductHandler(logger, productService)
Structured fields instead of string formatting
Intuitively you want to write this:
log.Info(fmt.Sprintf("order created: %v", order.ID)) // bad
But then the value goes into the msg string and is lost as a separate field — you can't filter on it.
The right way — separate key-value arguments:
h.log.InfoContext(ctx, "order_created",
slog.String("order_id", order.ID),
slog.String("customer_id", order.CustomerID),
)
The event name is a snake_case noun with a verb (order_created, payment_failed). Fields are in the same form (order_id, customer_id). This makes the record filterable: in Loki you can write {msg="order_created"} | order_id="ord-99".
Note InfoContext instead of Info: the context variants (InfoContext, WarnContext, ErrorContext) pass the active OpenTelemetry span to the bridge, which automatically adds trace_id and span_id to every record.
Log levels
Four levels — and each has a specific meaning:
ERROR — something went wrong that we didn't expect: a panic, a database failure, an unavailable payment provider. Always with an error field.
h.log.ErrorContext(ctx, "order_repository_failed",
slog.String("order_id", cmd.OrderID),
slog.String("error", err.Error()),
)
WARN — an expected degradation: a request failed validation, a connection retry, a third-party service is slow.
h.log.WarnContext(ctx, "payment_provider_retry",
slog.String("order_id", cmd.OrderID),
slog.Int("attempt", attempt),
)
INFO — a significant event in the domain: an order was created, a user registered, a task completed.
h.log.InfoContext(ctx, "order_confirmed",
slog.String("order_id", order.ID),
slog.String("customer_id", order.CustomerID),
)
DEBUG — details for debugging. Disabled by default in production.
h.log.DebugContext(ctx, "order_aggregate_snapshot",
slog.Any("order", order),
)
A common mistake: logging INFO on every incoming HTTP request right in the handler. That's noise — the access log is handled separately by the chi middleware.
requestId and userId through context.Context
Java has MDC — a storage of fields bound to a thread. Go has no threads, and goroutines have no local storage. Instead, fields travel through context.Context.
The middleware creates a request identifier and puts it in the context:
// internal/platform/middleware/reqid.go
type ctxKey string
const ctxRequestID ctxKey = "request_id"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = uuid.NewString()
}
ctx := context.WithValue(r.Context(), ctxRequestID, id)
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
At the edge of the handler a helper function enriches the logger with fields from the context:
// internal/platform/log/ctx.go
func FromCtx(ctx context.Context, log *slog.Logger) *slog.Logger {
var args []any
if id, ok := ctx.Value(ctxRequestID).(string); ok {
args = append(args, slog.String("request_id", id))
}
if uid, ok := ctx.Value(ctxUserID).(string); ok {
args = append(args, slog.String("user_id", uid))
}
return log.With(args...)
}
The HTTP handler calls it once at the start:
func (h *CustomerHTTPHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
log := platform_log.FromCtx(r.Context(), h.log)
customerID := chi.URLParam(r, "id")
profile, err := h.customers.GetProfile(r.Context(), customerID)
if err != nil {
log.WarnContext(r.Context(), "customer_profile_not_found",
slog.String("customer_id", customerID),
slog.String("error", err.Error()),
)
httperr.Write(w, r, err)
return
}
log.InfoContext(r.Context(), "customer_profile_fetched",
slog.String("customer_id", customerID),
)
render.JSON(w, r, profile)
}
The order of chi middleware matters: the OTel middleware is registered first (so the span is open), then RequestID and Auth:
r := chi.NewRouter()
r.Use(otelhttp.NewMiddleware("order-service"))
r.Use(RequestID)
r.Use(Auth(tokenVerifier))
Automatic traceId via the OTel bridge
If the project uses OpenTelemetry, trace_id and span_id can be added to logs automatically — through the otelslog bridge. Then you don't have to pull the trace identifier out of the context by hand.
// internal/platform/log/setup.go
import (
"go.opentelemetry.io/contrib/bridges/otelslog"
slogmulti "github.com/samber/slog-multi"
)
func NewWithOTel(env string) *slog.Logger {
var base slog.Handler
if env == "production" {
base = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
base = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
otelHandler := otelslog.NewHandler("order-service",
otelslog.WithLoggerProvider(otel.GetLoggerProvider()),
)
return slog.New(slogmulti.Fanout(base, otelHandler))
}
slogmulti.Fanout routes every record to both handlers at once: to stdout for the operator and to the OTel pipeline for the trace collector. It's enough to use InfoContext(ctx, ...) everywhere — the bridge pulls the span from the context itself.
Logging at the boundaries with the outside world
A good place to log is the points where the service crosses a boundary: an HTTP call to an external service, a database query, scheduler work.
The adapter to the payment provider:
// internal/payment/http_adapter.go
func (a *PaymentHTTPAdapter) Charge(ctx context.Context, cmd ChargeCommand) (*ChargeResult, error) {
a.log.InfoContext(ctx, "payment_charge_started",
slog.String("order_id", cmd.OrderID),
)
resp, err := a.client.Do(req.WithContext(ctx))
if err != nil {
a.log.ErrorContext(ctx, "payment_charge_network_failed",
slog.String("order_id", cmd.OrderID),
slog.String("error", err.Error()),
)
return nil, fmt.Errorf("charge: %w", err)
}
a.log.InfoContext(ctx, "payment_charge_completed",
slog.String("order_id", cmd.OrderID),
)
return result, nil
}
A database query:
// internal/order/postgres_repository.go
func (r *OrderPostgresRepository) Load(ctx context.Context, id string) (*Order, error) {
r.log.DebugContext(ctx, "order_load_started", slog.String("order_id", id))
row, err := r.queries.GetOrder(ctx, id)
if err != nil {
r.log.WarnContext(ctx, "order_not_found", slog.String("order_id", id))
return nil, &apperr.NotFoundError{Entity: "Order", ID: id}
}
return mapOrderRow(row), nil
}
Inside the business logic — only events for decisions taken. "Entered the method" or "loaded N rows" is noise, not information.
Personal data in logs
Personal data (full names, email, phone number, tokens, card data) must not be written to logs. The reason is simple: logs are kept longer than needed, accessible to more people than needed, and hard to clean up after the fact.
The rule: only identifiers go into the log (order_id, customer_id, amount), not field values (card_token, address, email).
Common mistakes and how to avoid them:
fmt.Sprintf("user: %+v", user)— all struct fields end up in the log, including email. Log onlyuser.ID.slog.Any("request_body", body)for a payment endpoint — a card number might slip in. Log only the needed fields explicitly.log.ErrorContext(ctx, err.Error())— an error message sometimes contains user-entered data. Useslog.String("error", err.Error())as a separate attribute and verify what ends up there.
In short
- Two handlers:
slog.NewJSONHandlerin production (for Loki/ELK),slog.NewTextHandlerin development — chosen byAPP_ENVat startup. - The logger is a struct field, passed through the constructor. Don't use
slog.Default()or global variables. - All variables — through
slog.String/slog.Int/slog.Any, not throughfmt.Sprintfinto the message string. - Always
InfoContext(ctx, ...), notInfo(...)— then the OTel bridge attachestrace_idandspan_idautomatically. requestIdanduserIdlive incontext.Context; the middleware puts them there, the handler reads them through a helper function.- Levels: ERROR — unexpected failure, WARN — expected degradation, INFO — domain event, DEBUG — debugging details.
- Personal data in logs is forbidden: only identifiers, not values.
What to read next
- Tracing in Go — the OTel-slog bridge, manual spans for handlers.
- Metrics in Go — promauto, RED middleware on chi, business metrics.
- Context propagation in Go — how requestId and userId travel through context.Context.