Passing a struct directly to slog is convenient until that struct grows a field that should never appear in logs. An access token, session secret, internal note, or large payload can turn an ordinary diagnostic line into a security problem or an expensive blob of noise.

Go’s slog.LogValuer interface gives a type control over its own structured log representation. Instead of teaching every call site which fields are safe, you can define that representation next to the type and let slog use it wherever the value is logged.

What slog.LogValuer does

The interface is deliberately small:

type LogValuer interface {
    LogValue() slog.Value
}

When a value implements slog.LogValuer, slog resolves it through LogValue before a handler formats the record. The method can return a string, number, boolean, duration, time, group, or another value supported by slog.Value.

A useful starting point is a domain type whose normal Go representation contains more information than operators need:

package main

import (
    "log/slog"
    "os"
)

type Credentials struct {
    Username string
    Token    string
}

func (c Credentials) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("username", c.Username),
        slog.String("token", "[redacted]"),
    )
}

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    creds := Credentials{
        Username: "rani",
        Token:    "secret-token-value",
    }

    logger.Info("credentials loaded", "credentials", creds)
}

The credentials attribute is emitted as a structured group, but the real token never reaches the handler. With JSONHandler, the relevant part of the record looks like this:

{
  "msg": "credentials loaded",
  "credentials": {
    "username": "rani",
    "token": "[redacted]"
  }
}

This is more robust than remembering to construct a safe anonymous struct at every logging call.

Put the logging policy on the type

A common application starts with logging like this:

logger.Info("user authenticated", "credentials", creds)

Without a custom representation, the output depends on how the handler converts the supplied Go value. More importantly, the call site doesn’t communicate which fields are intended for operational visibility.

You could spell out the fields each time:

logger.Info("user authenticated",
    "username", creds.Username,
    "token", "[redacted]",
)

That works, but it spreads the policy across the codebase. One caller may redact the token while another logs creds directly during debugging. If Credentials later gains a RefreshToken field, old call sites won’t know about it.

Implementing LogValue changes the ownership of that decision. The type says, in one place, “this is my log-safe representation.” Callers can then log the value without reconstructing that policy.

This pattern is especially useful for types that cross many layers: authentication data, payment identifiers, request metadata, customer records, and configuration objects. It is less useful for a tiny local struct that is logged once and has no sensitive or noisy fields. Not every type needs a logging method.

Return a group when the fields should stay queryable

Redaction doesn’t require flattening useful data into a single string. slog.GroupValue preserves individual fields so a JSON log processor can still query them.

Consider an HTTP endpoint value:

type Endpoint struct {
    Method string
    Host   string
    Path   string
}

func (e Endpoint) LogValue() slog.Value {
    return slog.GroupValue(
        slog.String("method", e.Method),
        slog.String("host", e.Host),
        slog.String("path", e.Path),
    )
}

A caller can attach it as one logical attribute:

logger.Info("upstream request", "endpoint", endpoint)

JSON output retains the hierarchy:

{
  "msg": "upstream request",
  "endpoint": {
    "method": "GET",
    "host": "api.example.test",
    "path": "/v1/orders"
  }
}

Grouping avoids collisions with unrelated fields. An outer method might describe the incoming request while endpoint.method describes an upstream request. Keeping those concepts separate makes log queries easier to understand.

Use a scalar value for identifiers with noisy internals

A type doesn’t have to return a group. Sometimes the useful log representation is just one scalar.

Suppose an application wraps an internal identifier:

type OrderID struct {
    value string
}

func (id OrderID) LogValue() slog.Value {
    return slog.StringValue(id.value)
}

Now this:

logger.Info("order queued", "order_id", orderID)

produces a normal string attribute instead of exposing the implementation details of OrderID.

This is a good fit for value objects where the wrapper exists for type safety but operators only need the canonical value. It also lets the internal representation change later without changing the log schema.

Redact by construction, not by key name alone

slog.HandlerOptions.ReplaceAttr can rewrite attributes before output, and it is useful for application-wide policies. For example, a handler can remove timestamps in tests or normalize particular keys.

For secrets carried by a domain type, LogValuer has a different advantage: the safe representation travels with the value. A Credentials value remains redacted when it is passed to another package using a different slog.Logger, as long as that logger’s handler follows normal slog value resolution.

Key-based redaction is easier to bypass accidentally:

logger.Info("request",
    "token", token,          // a filter may catch this
    "upstream_token", token, // will it catch this too?
)

A dedicated secret type makes the intent harder to lose:

type Secret string

func (Secret) LogValue() slog.Value {
    return slog.StringValue("[redacted]")
}

Then both attributes are safe when the value is passed as Secret:

var token Secret = "actual-secret"

logger.Info("request",
    "token", token,
    "upstream_token", token,
)

There is an important boundary here: LogValuer only helps when code logs the typed value. Converting it to a plain string first discards the protection:

logger.Info("request", "token", string(token)) // exposes the value

So treat a redacting type as one layer of defense, not permission to handle secrets carelessly elsewhere.

Be careful with pointer receivers and nil values

You can implement LogValue on either a value receiver or pointer receiver. A value receiver is convenient for small immutable value types because both the value and its pointer can satisfy the interface in normal use.

A pointer receiver is useful when copying the type is undesirable, but a nil pointer needs deliberate handling. This implementation panics if c is nil:

type Client struct {
    Name string
}

func (c *Client) LogValue() slog.Value {
    return slog.StringValue(c.Name)
}

If nil is a legitimate state, handle it explicitly:

func (c *Client) LogValue() slog.Value {
    if c == nil {
        return slog.StringValue("<nil>")
    }
    return slog.StringValue(c.Name)
}

The exact representation is an application decision. A string such as "<nil>" is easy to read, while slog.AnyValue(nil) better preserves null-like semantics for JSON consumers. Pick one based on the log schema you want rather than on convenience at the call site.

Keep LogValue predictable and cheap

LogValue is part of the logging path, so it should behave more like formatting code than business logic. Avoid network calls, database reads, lock-heavy work, or mutations inside it.

A method like this is a bad trade:

func (u User) LogValue() slog.Value {
    plan := lookupPlanFromDatabase(u.ID) // don't do this
    return slog.GroupValue(
        slog.String("id", u.ID),
        slog.String("plan", plan),
    )
}

Logging can happen on error paths where dependencies are already unhealthy. Adding I/O to value resolution can make failures slower and can introduce new failures while trying to record the original one.

There is also a subtler performance point. LogValuer can defer construction of a log value until the handler actually needs it, which can help when a record is disabled by log level. That doesn’t make expensive work automatically appropriate. If resolving the value requires substantial CPU or allocation, measure the cost on the enabled path and keep the method bounded.

Test the representation instead of eyeballing log lines

Because LogValue returns a slog.Value, you can test the contract directly without parsing complete log output.

For a scalar redacting type, the test is straightforward:

func TestSecretLogValue(t *testing.T) {
    secret := Secret("do-not-log-this")

    got := secret.LogValue()

    if got.Kind() != slog.KindString {
        t.Fatalf("kind = %v, want %v", got.Kind(), slog.KindString)
    }
    if got.String() != "[redacted]" {
        t.Fatalf("value = %q, want %q", got.String(), "[redacted]")
    }
}

For a group, inspect its attributes:

func TestCredentialsLogValueDoesNotExposeToken(t *testing.T) {
    creds := Credentials{
        Username: "rani",
        Token:    "do-not-log-this",
    }

    value := creds.LogValue()
    if value.Kind() != slog.KindGroup {
        t.Fatalf("kind = %v, want %v", value.Kind(), slog.KindGroup)
    }

    attrs := value.Group()
    for _, attr := range attrs {
        if attr.Value.String() == creds.Token {
            t.Fatalf("token leaked through attribute %q", attr.Key)
        }
    }
}

A higher-level test that writes through slog.NewJSONHandler is also worthwhile when the exact JSON schema is part of an ingestion contract. The direct unit test catches the domain rule; the handler test catches integration details.

Common mistakes with slog.LogValuer

The most serious mistake is assuming LogValuer sanitizes every possible representation of a value. It only controls what slog sees when the typed value reaches the logging API. fmt.Printf, JSON encoding, error formatting, metrics labels, traces, and explicit conversions follow their own rules.

Another mistake is returning one large formatted string from a type that has fields operators routinely query. This throws away the main benefit of structured logging. Prefer slog.GroupValue when the data has meaningful subfields.

Avoid changing field names casually once logs feed dashboards or alerts. A LogValue method effectively defines a small observability schema. Renaming user_id to id may be harmless to the application while silently breaking saved queries.

Finally, don’t expose a secret merely because it seems useful for debugging. A partial fingerprint, stable identifier, or explicit redaction marker is usually enough to correlate events without putting credentials into a log store.

A practical boundary for domain-aware logging

slog.LogValuer works best when a Go type has a clear operational representation that differs from its in-memory representation. Put stable, safe fields in that representation, keep secrets out, and preserve structure when those fields need to be queried independently.

For broader rules that aren’t owned by one type, such as renaming standard attributes or applying environment-specific formatting, keep using handler configuration. The two mechanisms complement each other: handlers define how the application emits logs, while LogValuer defines how a particular value should appear in them.

If you already pass domain structs to slog, review the ones containing credentials, personal data, large byte slices, or implementation-only fields first. Those are the places where a small LogValue method can remove the most risk without making every logging call more complicated.