Proposal Details

The current implementation of log/slog does not fully support logging arrays or slices. As a result, the type implementing the slog.Valuer interface is not respected when passed as an element of an array or slice and the Handler cannot handle the elements within the input array or slice either.

One possible workaround is to create a custom List using a generic struct that implements the slog.LogValuer interface, returns a Group and uses the index as the key. An example of this approach can be found in this comment https://github.com/golang/go/issues/63204#issuecomment-2428962553

However, it would make more sense and be more practical if List were natively supported

type listptr  *Value    // used in Value.any when the Value is a []Value

const (
    KindAny Kind = iota
    KindBool
    KindDuration
    KindFloat64
    KindInt64
    KindString
    KindTime
    KindUint64
    KindGroup
    KindLogValuer
        KindList 
)

// ListValue returns a [Value] for a slice of arbitrary values.
func ListValue(vs ...Value) Value {
    return Value{num: uint64(len(vs)), any: listptr(unsafe.SliceData(vs))}
}

// List returns v's value as a []Value.
// It panics if v's [Kind] is not [KindList].
func (v Value) List() []Value{
    if sp, ok := v.any.(listptr); ok {
        return unsafe.Slice((*Value)(sp), v.num)
    }
    panic("List: bad kind")
}

func (v Value) list() []Value{
    return unsafe.Slice((*Value)(v.any.(listptr)), v.num)
}

Comment From: ianlancetaylor

CC @jba

Comment From: jba

Can you provide more information?

  • Examples of where this is useful.

  • How a Handler would render a KindList. In particular the two build-in handlers.

  • Suggestions for how to deal with the breakage this would cause for all existing handlers.

I'm mostly concerned with adding a low-value feature that breaks a lot of existing code.

Comment From: longit644

Hi @jba,

Thank you for your response. Here’s more detail on how this proposal would be useful, especially in the context of how existing handlers would work with a KindList and the associated impact.

This proposal also addresses issue https://github.com/golang/go/issues/62699, independent of any specific format like JSON. By introducing KindList, we enable logging for slices or arrays without being tied to a single format such as JSON. This flexibility allows users to implement handlers for alternative formats, such as CBOR or MsgPack. As a result, the solution becomes more adaptable and extensible, catering to a broader range of use cases. This empowers users to easily customize the logging output according to their unique requirements.

I believe that KindList will be a great building block for log/slog, filling the missing piece for logging slices and arrays - fundamental data structures.

Examples of Where This Is Useful

This proposal is particularly useful in scenarios where we need to log sensitive or redacted data in structured logging systems, especially when the data is part of collections (e.g., slices, arrays). For example:

Redacting Sensitive Information in Lists

// A token is a secret value that grants permissions.
type Token string

// It avoids revealing the token.
func (Token) LogValue() slog.Value {
    return slog.StringValue("[REDACTED]")
}

func main() {
    tokens := []Token{"keep it secret", "keep it safe"}
    logger.Info("Permission revoked", slog.List("tokens", tokens...))
    // Or
    logger.Info("Permission revoked", "tokens", slog.ListValue(
        func(tokens []Token) []slog.Value {...}()..., // Transform tokens to slog.Values
    ))
}

Redacting Personally Identifiable Information (PII) Similarly, for more complex types like PhoneNumber or Address, the proposal allows for automatic redaction when logging lists of users.

type PhoneNumberType int

const (
    PhoneNumberTypeUnspecified = iota
    PhoneNumberTypeMobile
    PhoneNumberTypeHome
    PhoneNumberTypeWork
)

// Struct for phone numbers
type PhoneNumber struct {
    Type   PhoneNumberType
    Number string
}

// It avoids revealing the phone number.
func (p PhoneNumber) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int("type", p.Type),
        slog.String("number", "[REDACTED]"),
    )
}

type AddressType int

const (
    AddressTypeUnspecified = iota
    AddressTypeHome
    AddressTypeOffice
)

type Address struct {
    Type    AddressType
    Address string
}

// It avoids revealing the address.
func (a Address) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int("type", a.Type),
        slog.String("address", "[REDACTED]"),
    )
}

// Struct for User
type User struct {
    ID           int
    Name         string
    Email        string
    PhoneNumbers []PhoneNumber
    Addresses    []Address
}

// It avoids revealing the PII user data.
func (u User) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int("id", u.ID),
        slog.String("name", "[REDACTED]"),
        slog.String("email", mask.Email(u.Email)),
        slog.List("phone_numbers", u.PhoneNumbers...),
        slog.List("addresses", u.Addresses...),
    )
}

func main() {
    user := User{
        ID:           1,
        Name:         "Nguyen Van A",
        Email:        "a.nguyen@example.com",
        PhoneNumbers: []PhoneNumber{{Type: PhoneNumberTypeMobile, Number: "123-456-7890"}},
        Addresses:    []Address{{Type: AddressTypeHome, Address: "123 Hoang Dieu St"}},
    }
    logger.Info("User profile updated", "user", user)
}

How a Handler Would Render a KindList, In Particular the Two Built-In Handlers

TextHandler With TextHandler, the logged list will print with the index in logging key, like this:

logger.Info("Permission revoked", slog.List("tokens", tokens...))

Will print as:

token[0]=[REDACTED] token[1]=[REDACTED]

JSONHandler With JSONHandler, the list will be rendered as a normal JSON array:

logger.Info("Permission revoked", slog.List("tokens", tokens...))

Will print as:

tokens: ["[REDACTED]", "[REDACTED]"]

Suggestions for How to Deal with the Breakage This Would Cause for All Existing Handlers

I understand the concern about potential breakage, as many existing handlers may not expect a KindList and could lead to runtime errors or inconsistent outputs if the data structures aren’t handled correctly. However, by introducing a new type (which has not been used before by any users), we can ensure that the existing built-in or community handlers will not be affected by the change.

When this feature is released, the supported version of the built-in handlers will be shipped together. In the meantime, users can wait for community handlers to be updated before using this feature.

Let me know if you need further clarification or have any other concerns!

Comment From: jba

community handlers will not be affected by the change

I'm not sure what makes these special so that they are immune to the change. They could break too.

Also, your examples don't compile with your code, since the slog.ListValue function takes slog.Value, not any.

But maybe that's a good thing, because it suggests a simpler solution. What if the built-in handlers treated []Value specially, by converting it to []any with v.Resolve().Any() for each value in the slice? I said elsewhere that it's infeasible to walk every slice, but this is confined to []Value, which users have to go out of their way to create.

We could make this an "official" feature of slog.Handler by adding a check in testing/slogtest, so that other handlers would realize that they should add it (or explicitly opt out of doing so).

Now nothing will break, and the ecosystem will gradually improve in this direction.

Comment From: jaloren

@jba as luck would have it, I recently encountered this limitation and I can provide an experience report for why this feature would be nice to have. I am a SRE who uses go to write various orchestration tools. These tools need to interact with various different systems, where each system produces a stream of events into their logs. These events are semi-structured where some events are json documents and others are strings.

We strongly believe in structured logging where ever possible and default to json since its a common denominator that just about any system can parse reliably. As a consequence, the orchestration tool uses the slog JSON handler. When the tool encounters a failure with another system, the tool collects any events from the system and log them into slog as a list of events where the key is "events" and the value is a slice of Events. The slice of events will have polymorphic data types where each event can either be a string or a json document. I don't want the json documents to be rendered as strings because that's really hard for people to read and much harder for logging systems to parse.

I ended up with this:

https://go.dev/play/p/c5EoOgj4N73

I suspect your proposal for passing in a slice of values would work fine.

Comment From: seankhliao

Glancing through existing handler implementations, I don't think it's safe to add new Kinds, handlers may panic, add prefixes indicating it's unhandled, or silently drop the field. https://github.com/search?q=language%3AGo+%2Fcase+slog.kind%2F&type=code

Comment From: longit644

Hi @seankhliao, @jba

Thank you for your response!

Glancing through existing handler implementations, I don't think it's safe to add new Kinds, handlers may panic, add prefixes indicating it's unhandled, or silently drop the field. https://github.com/search?q=language%3AGo+%2Fcase+slog.kind%2F&type=code

I'm not sure what makes these special so that they are immune to the change. They could break too.

I share your concern, and I’ve carefully considered this as well. The potential for breaking changes only arises once users begin using the new List feature. Until then, the existing user code base should remain unaffected.

However, when users start using List, they will need to update their handlers to correctly handle the new Kind. If they don’t, the handlers may panic, drop fields, or behave unpredictably.

Comment From: seankhliao

The design of slog is to allow for the separation of the logging frontend (slog.Logger) and backend (slog.Handler). A third party package may accept being configured with a slog.Logger without needing to know what the actual slog.Handler is. In this case, you may not be aware of the need to update your handler to support the new kind until your application crashes because a package you didn't write but just upgraded used the new feature.

Comment From: longit644

Hi everyone, apologies for my long absence over the past few months. Thank you all for your thoughtful comments and feedback on the earlier version of this proposal.

After carefully considering the design and incorporating the suggestion from @jba, I now propose a simplified and backward-compatible approach to add native list support to slog. This leverages the existing KindAny and extends testing/slogtest, without introducing a new Kind.

Introduce New APIs

Constructors

We introduce functions to create a Value or Attr from a list:

type listptr *Value // used in Value.any when the Value is a []Value

// ListValue returns a Value representing a list of arbitrary values.
func ListValue[T any](vs ...T) Value {
    switch vs2 := any(vs).(type) {
    case []Value:
        return Value{num: uint64(len(vs2)), any: listptr(unsafe.SliceData(vs2))}
    default:
        return Value{
            num: uint64(len(vs)),
            any: listptr(unsafe.SliceData(argsToValueSlice(vs))),
        }
    }
}

// List creates an Attr whose value is a list of Values.
func List[T any](key string, args ...T) Attr {
    return Attr{key, ListValue(args...)}
}

func argsToValueSlice[T any](args []T) []Value {
    vs := make([]Value, 0, len(args))
    for _, arg := range args {
        vs = append(vs, AnyValue(arg))
    }
    return vs
}

Accessors

Handlers can extract the list with:

// List returns v's value as a []Value.
// Panics if the value is not a list.
func (v Value) List() []Value {
    if sp, ok := v.any.(listptr); ok {
        return unsafe.Slice((*Value)(sp), v.num)
    }
    panic("List: not a list value")
}

func (v Value) list() []Value {
    return unsafe.Slice((*Value)(v.any.(listptr)), v.num)
}

Helper

Because we introduce a new type of container (List) without adding a new Kind, these helper methods are useful for handlers to distinguish between container types:

// IsGroup reports whether the value is a group.
func (v Value) IsGroup() bool {
    _, ok := v.any.(groupptr)
    return ok
}

// IsList reports whether the value is a list.
func (v Value) IsList() bool {
    _, ok := v.any.(listptr)
    return ok
}

Update Existing APIs

Accessors

We update Value.Any() to support lists:


// Any returns v's value as an any.
func (v Value) Any() any {
    switch v.Kind() {
    case KindAny:
        switch vt := v.any.(type) {
        case kind:
            return Kind(vt)
        case listptr:
            return v.list()
        default:
            return v.any
        }
    // ...
    }
}

Other

Add support for comparing list values:

// Equal reports whether v and w represent the same Go value.
func (v Value) Equal(w Value) bool {
    k1 := v.Kind()
    k2 := w.Kind()
    if k1 != k2 {
        return false
    }
    switch k1 {
    case KindInt64, KindUint64, KindBool, KindDuration:
        return v.num == w.num
    case KindString:
        return v.str() == w.str()
    case KindFloat64:
        return v.float() == w.float()
    case KindTime:
        return v.time().Equal(w.time())
    case KindAny, KindLogValuer:
        if v.IsList() && w.IsList() {
            return slices.EqualFunc(v.list(), w.list(), Value.Equal)
        }

        return v.any == w.any // may panic if non-comparable
    case KindGroup:
        return slices.EqualFunc(v.group(), w.group(), Attr.Equal)
    default:
        panic(fmt.Sprintf("bad kind: %s", k1))
    }
}

Append text format of list to bytes.

// append appends a text representation of v to dst.
// v is formatted as with fmt.Sprint.
func (v Value) append(dst []byte) []byte {
    switch v.Kind() {
    case KindString:
        return append(dst, v.str()...)
    case KindInt64:
        return strconv.AppendInt(dst, int64(v.num), 10)
    case KindUint64:
        return strconv.AppendUint(dst, v.num, 10)
    case KindFloat64:
        return strconv.AppendFloat(dst, v.float(), 'g', -1, 64)
    case KindBool:
        return strconv.AppendBool(dst, v.bool())
    case KindDuration:
        return append(dst, v.duration().String()...)
    case KindTime:
        return append(dst, v.time().String()...)
    case KindGroup:
        return fmt.Append(dst, v.group())
    case KindAny, KindLogValuer:
        if _, ok := v.any.(listptr); ok {
            return fmt.Append(dst, v.list())
        }

        return fmt.Append(dst, v.any)
    default:
        panic(fmt.Sprintf("bad kind: %s", v.Kind()))
    }
}

ReplaceAttr

Since we’re adding a new container type (List), we replace groups []string with a more general path []string in ReplaceAttr:

// ReplaceAttr is called to rewrite each non-group attribute before it is
// logged. The attribute's value has been resolved (see [Value.Resolve]).
// If ReplaceAttr returns a zero Attr, the attribute is discarded.
//
// The built-in attributes with keys "time", "level", "source", and "msg"
// are passed to this function, except that time is omitted
// if zero, and source is omitted if AddSource is false.
//
// The first argument is the path to the current group that contains the
// Attr. It must not be retained or modified. ReplaceAttr is never called
// for Group attributes, only their contents. For example, the attribute
// list
//
//     Int("a", 1), Group("g", Int("b", 2)), Int("c", 3), List("l", GroupValue(Int("d", 4)))
//
// results in consecutive calls to ReplaceAttr with the following arguments:
//
//     nil, Int("a", 1)
//     []string{"g"}, Int("b", 2)
//     nil, Int("c", 3)
//     []string{"l", "[0]"}, Int("d", 4)
//
// ReplaceAttr can be used to change the default keys of the built-in
// attributes, convert types (for example, to replace a `time.Time` with the
// integer seconds since the Unix epoch), sanitize personal information, or
// remove attributes from the output.
ReplaceAttr func(path []string, a Attr) Attr

Example and output format

This example demonstrates how to log redacted tokens using both a plain slice and a type that implements slog.LogValuer.


// A token is a secret value that grants permissions.
type Token string

// LogValue implements slog.LogValuer.
// It avoids revealing the token.
func (Token) LogValue() slog.Value {
    return slog.StringValue("REDACTED_TOKEN")
}

// Tokens represents a list of tokens.
type Tokens []Token

// LogValue implements slog.LogValuer.
// It avoids revealing the actual tokens.
func (ts Tokens) LogValue() slog.Value {
    return slog.ListValue(ts...)
}

// This example demonstrates a Value that replaces itself
// with an alternative representation to avoid revealing secrets.
func ExampleLogValuer_secret() {
    t := Token("shhhh!")
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ReplaceAttr: slogtest.RemoveTime}))
    logger.Info("permission granted", "user", "Perry", "token", t)

    ts1 := []Token{"shhhh!", "whissss!"}
    logger.Info("user session info", "user", "Perry", "tokens", slog.ListValue(ts1...))

    ts2 := Tokens{"zhhhh!", "whizzzz!"}
    logger.Info("user session info", "user", "Heinz", "tokens", ts2)
}

Output

JSON format:
{"level":"INFO","msg":"permission granted","user":"Perry","token":"REDACTED_TOKEN"}
{"level":"INFO","msg":"user session info","user":"Perry","tokens":["REDACTED_TOKEN","REDACTED_TOKEN"]}
{"level":"INFO","msg":"user session info","user":"Heinz","tokens":["REDACTED_TOKEN","REDACTED_TOKEN"]}
Text format:
level=INFO msg="permission granted" user=Perry token=REDACTED_TOKEN
level=INFO msg="user session info" user=Perry tokens[0]=REDACTED_TOKEN tokens[1]=REDACTED_TOKEN
level=INFO msg="user session info" user=Heinz tokens[0]=REDACTED_TOKEN tokens[1]=REDACTED_TOKEN

Reference Implementation

You can find a working reference implementation with examples here: https://github.com/longit644/go/commit/b0de3ae198a03db8391f3d835ef52817ca0653b8

Comment From: longit644

Hi @jba, @seankhliao. Please take a look on this when you have moment. Thank you.

Comment From: jba

What's wrong with my suggestion on https://github.com/golang/go/issues/71088#issuecomment-2569217300? That requires no new API and only affects handlers. Can you describe what your recent proposal adds that would be better than just treating []Value specially?