Go's standard log/slog package is a solid default for Golang structured logging. It writes typed attributes through a stable API, supports text or JSON handlers, and needs no third-party logging dependency. For services, the useful baseline is JSON to stdout: the application owns event meaning, while the runtime and collector own delivery.
This guide builds that baseline with JSONHandler, typed attributes, groups, request context, error fields, redaction, level control, and output tests. The examples use only the Go standard library and work with Go 1.21 or later, when log/slog entered the standard library.
Build a JSON logger with log/slog
slog.NewJSONHandler writes one JSON object per record. Its built-in keys are time, level, and msg. Keep those names unchanged in the application logger and reserve them: do not add your own top-level attributes with the same keys. A collector can map the parsed built-ins to the names required by its destination later.
package main
import (
"context"
"errors"
"io"
"log/slog"
"os"
"strings"
"time"
)
func redactAttr(_ []string, attr slog.Attr) slog.Attr {
switch strings.ToLower(attr.Key) {
case "authorization", "password", "token":
return slog.String(attr.Key, "[REDACTED]")
default:
return attr
}
}
func newJSONLogger(w io.Writer, minLevel slog.Leveler) *slog.Logger {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
AddSource: true,
Level: minLevel,
ReplaceAttr: redactAttr,
})
return slog.New(handler).With(
slog.String("service_name", "checkout-api"),
slog.String("service_namespace", "storefront"),
slog.Group("labels",
slog.String("environment", "production"),
),
)
}
func main() {
var minLevel slog.LevelVar
minLevel.Set(slog.LevelInfo)
logger := newJSONLogger(os.Stdout, &minLevel)
ctx := context.Background()
logger.LogAttrs(ctx, slog.LevelInfo, "payment authorized",
slog.String("request_id", "req_8f2a"),
slog.Int("status_code", 201),
slog.Duration("duration", 82*time.Millisecond),
slog.Group("payment",
slog.String("provider", "primary"),
slog.Int64("amount_cents", 4299),
),
)
err := errors.New("upstream timeout")
logger.ErrorContext(ctx, "payment lookup failed",
slog.Any("error", err),
slog.String("request_id", "req_8f2a"),
slog.String("operation", "load_payment"),
)
}
The first record looks similar to this after formatting; the timestamp and source location depend on the build and runtime:
{
"time": "2026-09-15T14:18:09.412Z",
"level": "INFO",
"source": {
"function": "main.main",
"file": "/app/main.go",
"line": 45
},
"msg": "payment authorized",
"service_name": "checkout-api",
"service_namespace": "storefront",
"labels": {"environment": "production"},
"request_id": "req_8f2a",
"status_code": 201,
"duration": 82000000,
"payment": {"provider": "primary", "amount_cents": 4299}
}
JSONHandler actually writes each record on one line. The formatted block above is only easier to read. Durations use their integer nanosecond representation in JSON, so use a documented unit in the key if another representation would be clearer for your queries.
Prefer typed attributes and groups
Calls such as slog.String, slog.Int, slog.Bool, slog.Duration, and slog.Any make the expected field type visible in code. They also avoid malformed alternating key/value arguments. For frequently executed calls, Logger.LogAttrs accepts only slog.Attr values and is the efficient, unambiguous option documented by the Go team.
Choose stable names and types before the service is widely deployed. A field that is an integer in one service and a string in another makes shared queries harder. A small application schema is often enough:
- reserve
time,level,msg, andsourceforJSONHandleritself; service_nameandservice_namespaceidentify the producer;request_id,trace_id, orjob_idconnect related records;operationnames the work rather than repeating it in message prose;- numeric fields keep status, counts, and durations machine-readable;
labelsholds small, low-cardinality dimensions intended for exact filtering.
Groups keep related fields together without inventing dotted key names. slog.Group("payment", ...) becomes a nested JSON object under payment. Use groups when the namespace has meaning; avoid wrapping every attribute in another layer just because the API allows it.
Keep messages short and stable. "payment lookup failed" is easier to search and aggregate than a message containing a changing request ID, amount, or error string. Put those changing values in attributes.
Pass context deliberately
InfoContext, ErrorContext, and LogAttrs pass a context.Context to the handler. That lets a custom handler read approved context values, but the standard JSONHandler does not copy arbitrary context values into the record. Passing r.Context() alone therefore does not add a request ID or trace ID.
Create a scoped logger at the request or job boundary instead:
func handleCheckout(
ctx context.Context,
base *slog.Logger,
requestID string,
orderID int64,
) {
logger := base.With(
slog.String("request_id", requestID),
slog.Int64("order_id", orderID),
)
logger.InfoContext(ctx, "checkout started")
// Pass logger to functions that perform work for this checkout.
logger.InfoContext(ctx, "checkout completed")
}
The caller should obtain requestID from a validated upstream header or generate it with the service's real ID mechanism. Do not use a fixed fallback that makes unrelated requests look correlated.
Passing the context is still useful even with explicit attributes. A wrapping handler may use it for trace correlation, and Enabled can use it when deciding whether to accept a record. Context cancellation does not cancel log handling, so do not treat logging as request work that should disappear after a timeout.
Log errors as fields
Keep the message focused on the failed operation and attach the error separately:
if err != nil {
logger.ErrorContext(ctx, "order lookup failed",
slog.Any("error", err),
slog.String("operation", "load_order"),
slog.Int64("order_id", orderID),
)
return err
}
For a direct slog.Any("error", err) attribute, the built-in JSON handler writes the result of err.Error() as a string. That special handling does not apply to errors hidden inside a struct, slice, or map. Log the error directly, or define a safe LogValue method when a custom type needs a deliberate representation.
An error string can still contain a URL query, credential, customer data, or raw upstream response. Treat it as untrusted output. Prefer errors that identify the operation and cause without embedding secrets, and add safe classification fields such as operation, error_kind, or an internal code.
Redact before JSON reaches stdout
HandlerOptions.ReplaceAttr receives built-in attributes and the non-group attributes inside groups. It can replace or remove them. Returning an empty slog.Attr drops a field; the example replaces a few sensitive key names with [REDACTED].
Do not use ReplaceAttr to rename time, level, or msg in this logger. The callback does not identify whether a top-level attribute came from JSONHandler or application code. If application code also supplies msg, a rename can create duplicate message keys. Reserving the built-in names and mapping them after JSON parsing keeps the boundary explicit.
Key-based redaction is a last guard, not a complete privacy boundary. It cannot find a token embedded in msg, an error string, or an unexpected field name. Safer logging starts by selecting allowed attributes at the call site. Do not log request bodies, authorization headers, cookies, passwords, payment data, or entire user objects.
For sensitive application types, implement slog.LogValuer so their safe representation is automatic. The official log/slog documentation includes LogValuer and ReplaceAttr as the two main hooks for controlling how values and attributes appear.
Control levels and source details
The four built-in convenience levels are DEBUG, INFO, WARN, and ERROR. HandlerOptions.Level is a minimum: records below it are discarded. Its default is INFO.
A slog.LevelVar can change that minimum safely while the process is running:
var minLevel slog.LevelVar
minLevel.Set(slog.LevelInfo)
logger := newJSONLogger(os.Stdout, &minLevel)
minLevel.Set(slog.LevelDebug) // Restore INFO after the diagnostic window.
If a control endpoint changes the level, protect it like any other operational control and bound how long verbose logging remains enabled. Debug records can increase volume and expose details that normal production logs intentionally omit.
AddSource: true adds the function, file, and line when source information is available. That is useful while locating a call site, but file paths add payload size and can expose build paths. Disable it if your deployment does not need it.
Avoid building expensive debug attributes when debug is disabled. Check the level first:
if logger.Enabled(ctx, slog.LevelDebug) {
logger.DebugContext(ctx, "cache snapshot",
slog.Any("entries", buildCacheSummary()),
)
}
The guard matters only when constructing buildCacheSummary() is meaningfully expensive. Do not wrap every simple debug call in extra control flow.
Test the JSON contract
Treat field names, types, redaction, and level filtering as an output contract. A unit test can write to a buffer, decode the single JSON line, and assert the fields your collector depends on:
func TestJSONLogger(t *testing.T) {
var output bytes.Buffer
var minLevel slog.LevelVar
minLevel.Set(slog.LevelInfo)
logger := newJSONLogger(&output, &minLevel)
logger.Debug("not emitted")
logger.Info("checkout completed",
slog.String("request_id", "req_test"),
slog.String("token", "test-secret"),
)
var record map[string]any
if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &record); err != nil {
t.Fatalf("decode JSON log: %v", err)
}
if record["msg"] != "checkout completed" {
t.Fatalf("msg = %v", record["msg"])
}
if record["level"] != "INFO" {
t.Fatalf("level = %v", record["level"])
}
if record["service_name"] != "checkout-api" {
t.Fatalf("service_name = %v", record["service_name"])
}
if record["token"] != "[REDACTED]" {
t.Fatalf("token was not redacted")
}
if _, ok := record["time"].(string); !ok {
t.Fatalf("time = %v", record["time"])
}
for _, key := range []string{"timestamp", "severity", "message"} {
if _, exists := record[key]; exists {
t.Fatalf("unexpected pre-collector %s key", key)
}
}
}
This test belongs in the same package as newJSONLogger; add imports for bytes, encoding/json, and testing. If you implement a custom slog.Handler, use the standard library's testing/slogtest package to check handler behavior as well.
Tests should also cover the actual fields required by alerts or incident queries, empty values, nested groups, and any custom LogValuer. They catch schema drift before a deployment silently breaks a dashboard or filter.
Send Go JSON logs through a collector
Writing JSON to stdout keeps transport failures out of request code. Docker and Kubernetes runtimes capture stdout and stderr, then a node-level collector can add container or workload metadata, buffer during receiver outages, retry with limits, and deliver batches over TLS. A direct http.Client call from every log statement would need to solve those delivery and backpressure problems without slowing the application.
For Fluxtail, create an HTTP JSON receiver, copy its exact receiver URL, and use the receiver-bound Bearer token in the collector—not in the Go process or its output. The public Docker with Fluent Bit guide shows the supported collector path, including a read-only log mount and filesystem retry buffer. The related Docker container logs guide explains the broader collection model.
Raw JSONHandler output is not yet shaped for Fluxtail's HTTP JSON receiver. Fluxtail recommends non-empty top-level message plus timestamp, severity, service_name, and service_namespace where available. Configure the collector to parse the application JSON, then map only the parsed top-level time to timestamp, level to severity, and msg to message before sending it. The Fluxtail log payload reference defines the destination field names.
Container runtimes may wrap stdout in another JSON record, so the collector may need two steps: parse the runtime envelope, then parse its log string as the application's JSON. Keep nested groups nested. Do not apply a recursive rename that changes an application field named msg inside a group. Verify the actual collector configuration and received shape instead of assuming attributes automatically become Fluxtail filters.
Use a distinctive, non-sensitive marker such as checkout collector verification, then check the receiver's stream in Live Tail. Confirm:
- the marker appears in the expected stream;
service_name,severity, and timestamp have the expected values;- a nested group remains structured rather than becoming escaped text;
- container or workload metadata matches the emitting instance;
- collector retries and filesystem buffering behave as configured during a controlled receiver interruption.
Fluxtail supports message, service, severity, label, and Kubernetes filters, but the available filters depend on how the collector maps the incoming record. The search and filters documentation lists the supported query dimensions. Map only small exact-match dimensions into labels; keep high-cardinality values such as request IDs as structured fields or message-search values according to the verified ingest shape.
Production checklist
- Write one JSON object per line to stdout.
- Reserve
time,level,msg, andsourceforJSONHandler. - Keep messages stable and changing values in typed attributes.
- Standardize service, request, operation, and error fields; keep level meanings consistent.
- Use groups only when their namespace helps downstream queries.
- Pass context-aware calls, but attach approved correlation attributes explicitly.
- Redact at the call site, then add
ReplaceAttras another guard. - Test the JSON contract and minimum-level behavior.
- Let a collector handle parsing, field mapping, credentials, buffering, retries, and TLS delivery.
- Map only top-level
time,level, andmsgafter parsing and before Fluxtail HTTP JSON ingest. - Verify the fields that arrive before building filters or alerts around them.
That separation stays simple: log/slog produces standard JSON records, the collector parses and normalizes them for delivery, and Fluxtail provides the stream where those verified fields can be searched during an incident.