← Back to the section

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 only user.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. Use slog.String("error", err.Error()) as a separate attribute and verify what ends up there.

In short

  • Two handlers: slog.NewJSONHandler in production (for Loki/ELK), slog.NewTextHandler in development — chosen by APP_ENV at 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 through fmt.Sprintf into the message string.
  • Always InfoContext(ctx, ...), not Info(...) — then the OTel bridge attaches trace_id and span_id automatically.
  • requestId and userId live in context.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.
  • 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.