Source file src/log/slog/handler.go

     1  // Copyright 2022 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package slog
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"io"
    11  	"log/slog/internal/buffer"
    12  	"reflect"
    13  	"slices"
    14  	"strconv"
    15  	"sync"
    16  	"time"
    17  )
    18  
    19  // A Handler handles log records produced by a Logger.
    20  //
    21  // A typical handler may print log records to standard error,
    22  // or write them to a file or database, or perhaps augment them
    23  // with additional attributes and pass them on to another handler.
    24  //
    25  // Any of the Handler's methods may be called concurrently with itself
    26  // or with other methods. It is the responsibility of the Handler to
    27  // manage this concurrency.
    28  //
    29  // Users of the slog package should not invoke Handler methods directly.
    30  // They should use the methods of [Logger] instead.
    31  //
    32  // Before implementing your own handler, consult https://go.dev/s/slog-handler-guide.
    33  type Handler interface {
    34  	// Enabled reports whether the handler handles records at the given level.
    35  	// The handler ignores records whose level is lower.
    36  	// It is called early, before any arguments are processed,
    37  	// to save effort if the log event should be discarded.
    38  	// If called from a Logger method, the first argument is the context
    39  	// passed to that method, or context.Background() if nil was passed
    40  	// or the method does not take a context.
    41  	// The context is passed so Enabled can use its values
    42  	// to make a decision.
    43  	Enabled(context.Context, Level) bool
    44  
    45  	// Handle handles the Record.
    46  	// It will only be called when Enabled returns true.
    47  	// The Context argument is as for Enabled.
    48  	// It is present solely to provide Handlers access to the context's values.
    49  	// Canceling the context should not affect record processing.
    50  	// (Among other things, log messages may be necessary to debug a
    51  	// cancellation-related problem.)
    52  	//
    53  	// Handle methods that produce output should observe the following rules:
    54  	//   - If r.Time is the zero time, ignore the time.
    55  	//   - If r.PC is zero, ignore it.
    56  	//   - Attr's values should be resolved.
    57  	//   - If an Attr's key and value are both the zero value, ignore the Attr.
    58  	//     This can be tested with attr.Equal(Attr{}).
    59  	//   - If a group's key is empty, inline the group's Attrs.
    60  	//   - If a group has no Attrs (even if it has a non-empty key),
    61  	//     ignore it.
    62  	//
    63  	// [Logger] discards any errors from Handle. Wrap the Handle method to
    64  	// process any errors from Handlers.
    65  	Handle(context.Context, Record) error
    66  
    67  	// WithAttrs returns a new Handler whose attributes consist of
    68  	// both the receiver's attributes and the arguments.
    69  	// The Handler owns the slice: it may retain, modify or discard it.
    70  	WithAttrs(attrs []Attr) Handler
    71  
    72  	// WithGroup returns a new Handler with the given group appended to
    73  	// the receiver's existing groups.
    74  	// The keys of all subsequent attributes, whether added by With or in a
    75  	// Record, should be qualified by the sequence of group names.
    76  	//
    77  	// How this qualification happens is up to the Handler, so long as
    78  	// this Handler's attribute keys differ from those of another Handler
    79  	// with a different sequence of group names.
    80  	//
    81  	// A Handler should treat WithGroup as starting a Group of Attrs that ends
    82  	// at the end of the log event. That is,
    83  	//
    84  	//     logger.WithGroup("s").LogAttrs(ctx, level, msg, slog.Int("a", 1), slog.Int("b", 2))
    85  	//
    86  	// should behave like
    87  	//
    88  	//     logger.LogAttrs(ctx, level, msg, slog.Group("s", slog.Int("a", 1), slog.Int("b", 2)))
    89  	//
    90  	// If the name is empty, WithGroup returns the receiver.
    91  	WithGroup(name string) Handler
    92  }
    93  
    94  type defaultHandler struct {
    95  	ch *commonHandler
    96  	// internal.DefaultOutput, except for testing
    97  	output func(pc uintptr, data []byte) error
    98  }
    99  
   100  func newDefaultHandler(output func(uintptr, []byte) error) *defaultHandler {
   101  	return &defaultHandler{
   102  		ch:     &commonHandler{json: false},
   103  		output: output,
   104  	}
   105  }
   106  
   107  func (*defaultHandler) Enabled(_ context.Context, l Level) bool {
   108  	return l >= logLoggerLevel.Level()
   109  }
   110  
   111  // Collect the level, attributes and message in a string and
   112  // write it with the default log.Logger.
   113  // Let the log.Logger handle time and file/line.
   114  func (h *defaultHandler) Handle(ctx context.Context, r Record) error {
   115  	buf := buffer.New()
   116  	buf.WriteString(r.Level.String())
   117  	buf.WriteByte(' ')
   118  	buf.WriteString(r.Message)
   119  	state := h.ch.newHandleState(buf, true, " ")
   120  	defer state.free()
   121  	state.appendNonBuiltIns(r)
   122  	return h.output(r.PC, *buf)
   123  }
   124  
   125  func (h *defaultHandler) WithAttrs(as []Attr) Handler {
   126  	return &defaultHandler{h.ch.withAttrs(as), h.output}
   127  }
   128  
   129  func (h *defaultHandler) WithGroup(name string) Handler {
   130  	return &defaultHandler{h.ch.withGroup(name), h.output}
   131  }
   132  
   133  // HandlerOptions are options for a [TextHandler] or [JSONHandler].
   134  // A zero HandlerOptions consists entirely of default values.
   135  type HandlerOptions struct {
   136  	// AddSource causes the handler to compute the source code position
   137  	// of the log statement and add a SourceKey attribute to the output.
   138  	AddSource bool
   139  
   140  	// Level reports the minimum record level that will be logged.
   141  	// The handler discards records with lower levels.
   142  	// If Level is nil, the handler assumes LevelInfo.
   143  	// The handler calls Level.Level for each record processed;
   144  	// to adjust the minimum level dynamically, use a LevelVar.
   145  	Level Leveler
   146  
   147  	// ReplaceAttr is called to rewrite each non-group attribute before it is logged.
   148  	// The attribute's value has been resolved (see [Value.Resolve]).
   149  	// If ReplaceAttr returns a zero Attr, the attribute is discarded.
   150  	//
   151  	// The built-in attributes with keys "time", "level", "source", and "msg"
   152  	// are passed to this function, except that time is omitted
   153  	// if zero, and source is omitted if AddSource is false.
   154  	//
   155  	// The first argument is a list of currently open groups that contain the
   156  	// Attr. It must not be retained or modified. ReplaceAttr is never called
   157  	// for Group attributes, only their contents. For example, the attribute
   158  	// list
   159  	//
   160  	//     Int("a", 1), Group("g", Int("b", 2)), Int("c", 3)
   161  	//
   162  	// results in consecutive calls to ReplaceAttr with the following arguments:
   163  	//
   164  	//     nil, Int("a", 1)
   165  	//     []string{"g"}, Int("b", 2)
   166  	//     nil, Int("c", 3)
   167  	//
   168  	// ReplaceAttr can be used to change the default keys of the built-in
   169  	// attributes, convert types (for example, to replace a `time.Time` with the
   170  	// integer seconds since the Unix epoch), sanitize personal information, or
   171  	// remove attributes from the output.
   172  	ReplaceAttr func(groups []string, a Attr) Attr
   173  }
   174  
   175  // Keys for "built-in" attributes.
   176  const (
   177  	// TimeKey is the key used by the built-in handlers for the time
   178  	// when the log method is called. The associated Value is a [time.Time].
   179  	TimeKey = "time"
   180  	// LevelKey is the key used by the built-in handlers for the level
   181  	// of the log call. The associated value is a [Level].
   182  	LevelKey = "level"
   183  	// MessageKey is the key used by the built-in handlers for the
   184  	// message of the log call. The associated value is a string.
   185  	MessageKey = "msg"
   186  	// SourceKey is the key used by the built-in handlers for the source file
   187  	// and line of the log call. The associated value is a *[Source].
   188  	SourceKey = "source"
   189  )
   190  
   191  type commonHandler struct {
   192  	json              bool // true => output JSON; false => output text
   193  	opts              HandlerOptions
   194  	preformattedAttrs []byte
   195  	// groupPrefix is for the text handler only.
   196  	// It holds the prefix for groups that were already pre-formatted.
   197  	// A group will appear here when a call to WithGroup is followed by
   198  	// a call to WithAttrs.
   199  	groupPrefix string
   200  	groups      []string // all groups started from WithGroup
   201  	nOpenGroups int      // the number of groups opened in preformattedAttrs
   202  	mu          *sync.Mutex
   203  	w           io.Writer
   204  }
   205  
   206  func (h *commonHandler) clone() *commonHandler {
   207  	return &commonHandler{
   208  		json:              h.json,
   209  		opts:              h.opts,
   210  		preformattedAttrs: slices.Clip(h.preformattedAttrs),
   211  		groupPrefix:       h.groupPrefix,
   212  		groups:            slices.Clip(h.groups),
   213  		nOpenGroups:       h.nOpenGroups,
   214  		w:                 h.w,
   215  		mu:                h.mu, // mutex shared among all clones of this handler
   216  	}
   217  }
   218  
   219  // enabled reports whether l is greater than or equal to the
   220  // minimum level.
   221  func (h *commonHandler) enabled(l Level) bool {
   222  	minLevel := LevelInfo
   223  	if h.opts.Level != nil {
   224  		minLevel = h.opts.Level.Level()
   225  	}
   226  	return l >= minLevel
   227  }
   228  
   229  func (h *commonHandler) withAttrs(as []Attr) *commonHandler {
   230  	// We are going to ignore empty groups, so if the entire slice consists of
   231  	// them, there is nothing to do.
   232  	if countEmptyGroups(as) == len(as) {
   233  		return h
   234  	}
   235  	h2 := h.clone()
   236  	// Pre-format the attributes as an optimization.
   237  	state := h2.newHandleState((*buffer.Buffer)(&h2.preformattedAttrs), false, "")
   238  	defer state.free()
   239  	state.prefix.WriteString(h.groupPrefix)
   240  	if pfa := h2.preformattedAttrs; len(pfa) > 0 {
   241  		state.sep = h.attrSep()
   242  		if h2.json && pfa[len(pfa)-1] == '{' {
   243  			state.sep = ""
   244  		}
   245  	}
   246  	// Remember the position in the buffer, in case all attrs are empty.
   247  	pos := state.buf.Len()
   248  	state.openGroups()
   249  	if !state.appendAttrs(as) {
   250  		state.buf.SetLen(pos)
   251  	} else {
   252  		// Remember the new prefix for later keys.
   253  		h2.groupPrefix = state.prefix.String()
   254  		// Remember how many opened groups are in preformattedAttrs,
   255  		// so we don't open them again when we handle a Record.
   256  		h2.nOpenGroups = len(h2.groups)
   257  	}
   258  	return h2
   259  }
   260  
   261  func (h *commonHandler) withGroup(name string) *commonHandler {
   262  	h2 := h.clone()
   263  	h2.groups = append(h2.groups, name)
   264  	return h2
   265  }
   266  
   267  // handle is the internal implementation of Handler.Handle
   268  // used by TextHandler and JSONHandler.
   269  func (h *commonHandler) handle(r Record) error {
   270  	state := h.newHandleState(buffer.New(), true, "")
   271  	defer state.free()
   272  	if h.json {
   273  		state.buf.WriteByte('{')
   274  	}
   275  	// Built-in attributes. They are not in a group.
   276  	stateGroups := state.groups
   277  	state.groups = nil // So ReplaceAttrs sees no groups instead of the pre groups.
   278  	rep := h.opts.ReplaceAttr
   279  	// time
   280  	if !r.Time.IsZero() {
   281  		key := TimeKey
   282  		val := r.Time.Round(0) // strip monotonic to match Attr behavior
   283  		if rep == nil {
   284  			state.appendKey(key)
   285  			state.appendTime(val)
   286  		} else {
   287  			state.appendAttr(Time(key, val))
   288  		}
   289  	}
   290  	// level
   291  	key := LevelKey
   292  	val := r.Level
   293  	if rep == nil {
   294  		state.appendKey(key)
   295  		state.appendString(val.String())
   296  	} else {
   297  		state.appendAttr(Any(key, val))
   298  	}
   299  	// source
   300  	if h.opts.AddSource {
   301  		src := r.Source()
   302  		if src == nil {
   303  			src = &Source{}
   304  		}
   305  		state.appendAttr(Any(SourceKey, src))
   306  	}
   307  	key = MessageKey
   308  	msg := r.Message
   309  	if rep == nil {
   310  		state.appendKey(key)
   311  		state.appendString(msg)
   312  	} else {
   313  		state.appendAttr(String(key, msg))
   314  	}
   315  	state.groups = stateGroups // Restore groups passed to ReplaceAttrs.
   316  	state.appendNonBuiltIns(r)
   317  	state.buf.WriteByte('\n')
   318  
   319  	h.mu.Lock()
   320  	defer h.mu.Unlock()
   321  	_, err := h.w.Write(*state.buf)
   322  	return err
   323  }
   324  
   325  func (s *handleState) appendNonBuiltIns(r Record) {
   326  	// preformatted Attrs
   327  	if pfa := s.h.preformattedAttrs; len(pfa) > 0 {
   328  		s.buf.WriteString(s.sep)
   329  		s.buf.Write(pfa)
   330  		s.sep = s.h.attrSep()
   331  		if s.h.json && pfa[len(pfa)-1] == '{' {
   332  			s.sep = ""
   333  		}
   334  	}
   335  	// Attrs in Record -- unlike the built-in ones, they are in groups started
   336  	// from WithGroup.
   337  	// If the record has no Attrs, don't output any groups.
   338  	nOpenGroups := s.h.nOpenGroups
   339  	if r.NumAttrs() > 0 {
   340  		s.prefix.WriteString(s.h.groupPrefix)
   341  		// The group may turn out to be empty even though it has attrs (for
   342  		// example, ReplaceAttr may delete all the attrs).
   343  		// So remember where we are in the buffer, to restore the position
   344  		// later if necessary.
   345  		pos := s.buf.Len()
   346  		s.openGroups()
   347  		nOpenGroups = len(s.h.groups)
   348  		empty := true
   349  		r.Attrs(func(a Attr) bool {
   350  			if s.appendAttr(a) {
   351  				empty = false
   352  			}
   353  			return true
   354  		})
   355  		if empty {
   356  			s.buf.SetLen(pos)
   357  			nOpenGroups = s.h.nOpenGroups
   358  		}
   359  	}
   360  	if s.h.json {
   361  		// Close all open groups.
   362  		for range s.h.groups[:nOpenGroups] {
   363  			s.buf.WriteByte('}')
   364  		}
   365  		// Close the top-level object.
   366  		s.buf.WriteByte('}')
   367  	}
   368  }
   369  
   370  // attrSep returns the separator between attributes.
   371  func (h *commonHandler) attrSep() string {
   372  	if h.json {
   373  		return ","
   374  	}
   375  	return " "
   376  }
   377  
   378  // handleState holds state for a single call to commonHandler.handle.
   379  // The initial value of sep determines whether to emit a separator
   380  // before the next key, after which it stays true.
   381  type handleState struct {
   382  	h       *commonHandler
   383  	buf     *buffer.Buffer
   384  	freeBuf bool           // should buf be freed?
   385  	sep     string         // separator to write before next key
   386  	prefix  *buffer.Buffer // for text: key prefix
   387  	groups  *[]string      // pool-allocated slice of active groups, for ReplaceAttr
   388  }
   389  
   390  var groupPool = sync.Pool{New: func() any {
   391  	s := make([]string, 0, 10)
   392  	return &s
   393  }}
   394  
   395  func (h *commonHandler) newHandleState(buf *buffer.Buffer, freeBuf bool, sep string) handleState {
   396  	s := handleState{
   397  		h:       h,
   398  		buf:     buf,
   399  		freeBuf: freeBuf,
   400  		sep:     sep,
   401  		prefix:  buffer.New(),
   402  	}
   403  	if h.opts.ReplaceAttr != nil {
   404  		s.groups = groupPool.Get().(*[]string)
   405  		*s.groups = append(*s.groups, h.groups[:h.nOpenGroups]...)
   406  	}
   407  	return s
   408  }
   409  
   410  func (s *handleState) free() {
   411  	if s.freeBuf {
   412  		s.buf.Free()
   413  	}
   414  	if gs := s.groups; gs != nil {
   415  		*gs = (*gs)[:0]
   416  		groupPool.Put(gs)
   417  	}
   418  	s.prefix.Free()
   419  }
   420  
   421  func (s *handleState) openGroups() {
   422  	for _, n := range s.h.groups[s.h.nOpenGroups:] {
   423  		s.openGroup(n)
   424  	}
   425  }
   426  
   427  // Separator for group names and keys.
   428  const keyComponentSep = '.'
   429  
   430  // openGroup starts a new group of attributes
   431  // with the given name.
   432  func (s *handleState) openGroup(name string) {
   433  	if s.h.json {
   434  		s.appendKey(name)
   435  		s.buf.WriteByte('{')
   436  		s.sep = ""
   437  	} else {
   438  		s.prefix.WriteString(name)
   439  		s.prefix.WriteByte(keyComponentSep)
   440  	}
   441  	// Collect group names for ReplaceAttr.
   442  	if s.groups != nil {
   443  		*s.groups = append(*s.groups, name)
   444  	}
   445  }
   446  
   447  // closeGroup ends the group with the given name.
   448  func (s *handleState) closeGroup(name string) {
   449  	if s.h.json {
   450  		s.buf.WriteByte('}')
   451  	} else {
   452  		(*s.prefix) = (*s.prefix)[:len(*s.prefix)-len(name)-1 /* for keyComponentSep */]
   453  	}
   454  	s.sep = s.h.attrSep()
   455  	if s.groups != nil {
   456  		*s.groups = (*s.groups)[:len(*s.groups)-1]
   457  	}
   458  }
   459  
   460  // appendAttrs appends the slice of Attrs.
   461  // It reports whether something was appended.
   462  func (s *handleState) appendAttrs(as []Attr) bool {
   463  	nonEmpty := false
   464  	for _, a := range as {
   465  		if s.appendAttr(a) {
   466  			nonEmpty = true
   467  		}
   468  	}
   469  	return nonEmpty
   470  }
   471  
   472  // appendAttr appends the Attr's key and value.
   473  // It handles replacement and checking for an empty key.
   474  // It reports whether something was appended.
   475  func (s *handleState) appendAttr(a Attr) bool {
   476  	a.Value = a.Value.Resolve()
   477  	if rep := s.h.opts.ReplaceAttr; rep != nil && a.Value.Kind() != KindGroup {
   478  		var gs []string
   479  		if s.groups != nil {
   480  			gs = *s.groups
   481  		}
   482  		// a.Value is resolved before calling ReplaceAttr, so the user doesn't have to.
   483  		a = rep(gs, a)
   484  		// The ReplaceAttr function may return an unresolved Attr.
   485  		a.Value = a.Value.Resolve()
   486  	}
   487  	// Elide empty Attrs.
   488  	if a.isEmpty() {
   489  		return false
   490  	}
   491  	// Special case: Source.
   492  	if v := a.Value; v.Kind() == KindAny {
   493  		if src, ok := v.Any().(*Source); ok {
   494  			if src.isEmpty() {
   495  				return false
   496  			}
   497  			if s.h.json {
   498  				a.Value = src.group()
   499  			} else {
   500  				a.Value = StringValue(fmt.Sprintf("%s:%d", src.File, src.Line))
   501  			}
   502  		}
   503  	}
   504  	if a.Value.Kind() == KindGroup {
   505  		attrs := a.Value.Group()
   506  		// Output only non-empty groups.
   507  		if len(attrs) > 0 {
   508  			// The group may turn out to be empty even though it has attrs (for
   509  			// example, ReplaceAttr may delete all the attrs).
   510  			// So remember where we are in the buffer, to restore the position
   511  			// later if necessary.
   512  			pos := s.buf.Len()
   513  			// Inline a group with an empty key.
   514  			if a.Key != "" {
   515  				s.openGroup(a.Key)
   516  			}
   517  			if !s.appendAttrs(attrs) {
   518  				s.buf.SetLen(pos)
   519  				return false
   520  			}
   521  			if a.Key != "" {
   522  				s.closeGroup(a.Key)
   523  			}
   524  		}
   525  	} else {
   526  		s.appendKey(a.Key)
   527  		s.appendValue(a.Value)
   528  	}
   529  	return true
   530  }
   531  
   532  func (s *handleState) appendError(err error) {
   533  	s.appendString(fmt.Sprintf("!ERROR:%v", err))
   534  }
   535  
   536  func (s *handleState) appendKey(key string) {
   537  	s.buf.WriteString(s.sep)
   538  	if s.prefix != nil && len(*s.prefix) > 0 {
   539  		s.appendTwoStrings(string(*s.prefix), key)
   540  	} else {
   541  		s.appendString(key)
   542  	}
   543  	if s.h.json {
   544  		s.buf.WriteByte(':')
   545  	} else {
   546  		s.buf.WriteByte('=')
   547  	}
   548  	s.sep = s.h.attrSep()
   549  }
   550  
   551  // appendTwoStrings implements appendString(prefix + key), but faster.
   552  func (s *handleState) appendTwoStrings(x, y string) {
   553  	buf := *s.buf
   554  	switch {
   555  	case s.h.json:
   556  		buf.WriteByte('"')
   557  		buf = appendEscapedJSONString(buf, x)
   558  		buf = appendEscapedJSONString(buf, y)
   559  		buf.WriteByte('"')
   560  	case !needsQuoting(x) && !needsQuoting(y):
   561  		buf.WriteString(x)
   562  		buf.WriteString(y)
   563  	default:
   564  		buf = strconv.AppendQuote(buf, x+y)
   565  	}
   566  	*s.buf = buf
   567  }
   568  
   569  func (s *handleState) appendString(str string) {
   570  	if s.h.json {
   571  		s.buf.WriteByte('"')
   572  		*s.buf = appendEscapedJSONString(*s.buf, str)
   573  		s.buf.WriteByte('"')
   574  	} else {
   575  		// text
   576  		if needsQuoting(str) {
   577  			*s.buf = strconv.AppendQuote(*s.buf, str)
   578  		} else {
   579  			s.buf.WriteString(str)
   580  		}
   581  	}
   582  }
   583  
   584  func (s *handleState) appendValue(v Value) {
   585  	defer func() {
   586  		if r := recover(); r != nil {
   587  			// If it panics with a nil pointer, the most likely cases are
   588  			// an encoding.TextMarshaler or error fails to guard against nil,
   589  			// in which case "<nil>" seems to be the feasible choice.
   590  			//
   591  			// Adapted from the code in fmt/print.go.
   592  			if v := reflect.ValueOf(v.any); v.Kind() == reflect.Pointer && v.IsNil() {
   593  				s.appendString("<nil>")
   594  				return
   595  			}
   596  
   597  			// Otherwise just print the original panic message.
   598  			s.appendString(fmt.Sprintf("!PANIC: %v", r))
   599  		}
   600  	}()
   601  
   602  	var err error
   603  	if s.h.json {
   604  		err = appendJSONValue(s, v)
   605  	} else {
   606  		err = appendTextValue(s, v)
   607  	}
   608  	if err != nil {
   609  		s.appendError(err)
   610  	}
   611  }
   612  
   613  func (s *handleState) appendTime(t time.Time) {
   614  	if s.h.json {
   615  		appendJSONTime(s, t)
   616  	} else {
   617  		*s.buf = appendRFC3339Millis(*s.buf, t)
   618  	}
   619  }
   620  
   621  func appendRFC3339Millis(b []byte, t time.Time) []byte {
   622  	// Format according to time.RFC3339Nano since it is highly optimized,
   623  	// but truncate it to use millisecond resolution.
   624  	// Unfortunately, that format trims trailing 0s, so add 1/10 millisecond
   625  	// to guarantee that there are exactly 4 digits after the period.
   626  	const prefixLen = len("2006-01-02T15:04:05.000")
   627  	n := len(b)
   628  	t = t.Truncate(time.Millisecond).Add(time.Millisecond / 10)
   629  	b = t.AppendFormat(b, time.RFC3339Nano)
   630  	b = append(b[:n+prefixLen], b[n+prefixLen+1:]...) // drop the 4th digit
   631  	return b
   632  }
   633  
   634  // DiscardHandler discards all log output.
   635  // DiscardHandler.Enabled returns false for all Levels.
   636  var DiscardHandler Handler = discardHandler{}
   637  
   638  type discardHandler struct{}
   639  
   640  func (dh discardHandler) Enabled(context.Context, Level) bool  { return false }
   641  func (dh discardHandler) Handle(context.Context, Record) error { return nil }
   642  func (dh discardHandler) WithAttrs(attrs []Attr) Handler       { return dh }
   643  func (dh discardHandler) WithGroup(name string) Handler        { return dh }
   644  

View as plain text