...

Source file src/cdr.dev/slog/internal/entryhuman/entry.go

Documentation: cdr.dev/slog/internal/entryhuman

     1  // Package entryhuman contains the code to format slog.SinkEntry
     2  // for humans.
     3  package entryhuman
     4  
     5  import (
     6  	"bytes"
     7  	"encoding/json"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"path/filepath"
    12  	"runtime/debug"
    13  	"strconv"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/fatih/color"
    18  	"go.opencensus.io/trace"
    19  	"golang.org/x/crypto/ssh/terminal"
    20  	"golang.org/x/xerrors"
    21  
    22  	"cdr.dev/slog"
    23  )
    24  
    25  // StripTimestamp strips the timestamp from entry and returns
    26  // it and the rest of the entry.
    27  func StripTimestamp(ent string) (time.Time, string, error) {
    28  	ts := ent[:len(TimeFormat)]
    29  	rest := ent[len(TimeFormat):]
    30  	et, err := time.Parse(TimeFormat, ts)
    31  	return et, rest, err
    32  }
    33  
    34  // TimeFormat is a simplified RFC3339 format.
    35  const TimeFormat = "2006-01-02 15:04:05.000"
    36  
    37  func c(w io.Writer, attrs ...color.Attribute) *color.Color {
    38  	c := color.New(attrs...)
    39  	c.DisableColor()
    40  	if shouldColor(w) {
    41  		c.EnableColor()
    42  	}
    43  	return c
    44  }
    45  
    46  // Fmt returns a human readable format for ent.
    47  //
    48  // We never return with a trailing newline because Go's testing framework adds one
    49  // automatically and if we include one, then we'll get two newlines.
    50  // We also do not indent the fields as go's test does that automatically
    51  // for extra lines in a log so if we did it here, the fields would be indented
    52  // twice in test logs. So the Stderr logger indents all the fields itself.
    53  func Fmt(w io.Writer, ent slog.SinkEntry) string {
    54  	ents := c(w, color.Reset).Sprint("")
    55  	ts := ent.Time.Format(TimeFormat)
    56  	ents += ts + " "
    57  
    58  	level := "[" + ent.Level.String() + "]"
    59  	level = c(w, levelColor(ent.Level)).Sprint(level)
    60  	ents += fmt.Sprintf("%v\t", level)
    61  
    62  	if len(ent.LoggerNames) > 0 {
    63  		loggerName := "(" + quoteKey(strings.Join(ent.LoggerNames, ".")) + ")"
    64  		loggerName = c(w, color.FgMagenta).Sprint(loggerName)
    65  		ents += fmt.Sprintf("%v\t", loggerName)
    66  	}
    67  
    68  	hpath, hfn := humanPathAndFunc(ent.File, ent.Func)
    69  	loc := fmt.Sprintf("<%v:%v>\t%v", hpath, ent.Line, hfn)
    70  	loc = c(w, color.FgCyan).Sprint(loc)
    71  	ents += fmt.Sprintf("%v\t", loc)
    72  
    73  	var multilineKey string
    74  	var multilineVal string
    75  	msg := strings.TrimSpace(ent.Message)
    76  	if strings.Contains(msg, "\n") {
    77  		multilineKey = "msg"
    78  		multilineVal = msg
    79  		msg = "..."
    80  	}
    81  	msg = quote(msg)
    82  	ents += msg
    83  
    84  	if ent.SpanContext != (trace.SpanContext{}) {
    85  		ent.Fields = append(slog.M(
    86  			slog.F("trace", ent.SpanContext.TraceID),
    87  			slog.F("span", ent.SpanContext.SpanID),
    88  		), ent.Fields...)
    89  	}
    90  
    91  	for i, f := range ent.Fields {
    92  		if multilineVal != "" {
    93  			break
    94  		}
    95  
    96  		var s string
    97  		switch v := f.Value.(type) {
    98  		case string:
    99  			s = v
   100  		case error, xerrors.Formatter:
   101  			s = fmt.Sprintf("%+v", v)
   102  		}
   103  		s = strings.TrimSpace(s)
   104  		if !strings.Contains(s, "\n") {
   105  			continue
   106  		}
   107  
   108  		// Remove this field.
   109  		ent.Fields = append(ent.Fields[:i], ent.Fields[i+1:]...)
   110  		multilineKey = f.Name
   111  		multilineVal = s
   112  	}
   113  
   114  	if len(ent.Fields) > 0 {
   115  		// No error is guaranteed due to slog.Map handling errors itself.
   116  		fields, _ := json.MarshalIndent(ent.Fields, "", "")
   117  		fields = bytes.ReplaceAll(fields, []byte(",\n"), []byte(", "))
   118  		fields = bytes.ReplaceAll(fields, []byte("\n"), []byte(""))
   119  		fields = formatJSON(w, fields)
   120  		ents += "\t" + string(fields)
   121  	}
   122  
   123  	if multilineVal != "" {
   124  		if msg != "..." {
   125  			ents += " ..."
   126  		}
   127  
   128  		// Proper indentation.
   129  		lines := strings.Split(multilineVal, "\n")
   130  		for i, line := range lines[1:] {
   131  			if line != "" {
   132  				lines[i+1] = c(w, color.Reset).Sprint("") + strings.Repeat(" ", len(multilineKey)+4) + line
   133  			}
   134  		}
   135  		multilineVal = strings.Join(lines, "\n")
   136  
   137  		multilineKey = c(w, color.FgBlue).Sprintf(`"%v"`, multilineKey)
   138  		ents += fmt.Sprintf("\n%v: %v", multilineKey, multilineVal)
   139  	}
   140  
   141  	return ents
   142  }
   143  
   144  func levelColor(level slog.Level) color.Attribute {
   145  	switch level {
   146  	case slog.LevelDebug:
   147  		return color.Reset
   148  	case slog.LevelInfo:
   149  		return color.FgBlue
   150  	case slog.LevelWarn:
   151  		return color.FgYellow
   152  	case slog.LevelError:
   153  		return color.FgRed
   154  	default:
   155  		return color.FgHiRed
   156  	}
   157  }
   158  
   159  var forceColorWriter = io.Writer(&bytes.Buffer{})
   160  
   161  // isTTY checks whether the given writer is a *os.File TTY.
   162  func isTTY(w io.Writer) bool {
   163  	if w == forceColorWriter {
   164  		return true
   165  	}
   166  	f, ok := w.(interface {
   167  		Fd() uintptr
   168  	})
   169  	return ok && terminal.IsTerminal(int(f.Fd()))
   170  }
   171  
   172  func shouldColor(w io.Writer) bool {
   173  	return isTTY(w) || os.Getenv("FORCE_COLOR") != ""
   174  }
   175  
   176  // quotes quotes a string so that it is suitable
   177  // as a key for a map or in general some output that
   178  // cannot span multiple lines or have weird characters.
   179  func quote(key string) string {
   180  	// strconv.Quote does not quote an empty string so we need this.
   181  	if key == "" {
   182  		return `""`
   183  	}
   184  
   185  	quoted := strconv.Quote(key)
   186  	// If the key doesn't need to be quoted, don't quote it.
   187  	// We do not use strconv.CanBackquote because it doesn't
   188  	// account tabs.
   189  	if quoted[1:len(quoted)-1] == key {
   190  		return key
   191  	}
   192  	return quoted
   193  }
   194  
   195  func quoteKey(key string) string {
   196  	// Replace spaces in the map keys with underscores.
   197  	return strings.ReplaceAll(key, " ", "_")
   198  }
   199  
   200  var mainPackagePath string
   201  var mainModulePath string
   202  
   203  func init() {
   204  	// Unfortunately does not work for tests yet :(
   205  	// See https://github.com/golang/go/issues/33976
   206  	bi, ok := debug.ReadBuildInfo()
   207  	if !ok {
   208  		return
   209  	}
   210  	mainPackagePath = bi.Path
   211  	mainModulePath = bi.Main.Path
   212  }
   213  
   214  // humanPathAndFunc takes the absolute path to a file and an absolute module path to a
   215  // function in that file and returns the module path to the file. It also returns
   216  // the path to the function stripped of its module prefix.
   217  //
   218  // If the file is in the main Go module then its path is returned
   219  // relative to the main Go module's root.
   220  //
   221  // fn is from https://pkg.go.dev/runtime#Func.Name
   222  func humanPathAndFunc(filename, fn string) (hpath, hfn string) {
   223  	// pkgDir is the dir of the pkg.
   224  	//   e.g. cdr.dev/slog/internal
   225  	// base is the package name and the function name separated by a period.
   226  	//   e.g. entryhuman.humanPathAndFunc
   227  	// There can be multiple periods when methods of types are involved.
   228  	pkgDir, base := filepath.Split(fn)
   229  	s := strings.Split(base, ".")
   230  	pkg := s[0]
   231  	hfn = strings.Join(s[1:], ".")
   232  
   233  	if pkg == "main" {
   234  		// This happens with go build main.go
   235  		if mainPackagePath == "command-line-arguments" {
   236  			// Without a real mainPath, we can't find the path to the file
   237  			// relative to the module. So we just return the base.
   238  			return filepath.Base(filename), hfn
   239  		}
   240  		// Go doesn't return the full main path in runtime.Func.Name()
   241  		// It just returns the path "main"
   242  		// Only runtime.ReadBuildInfo returns it so we have to check and replace.
   243  		pkgDir = mainPackagePath
   244  		// pkg main isn't reflected on the disk so we should not add it
   245  		// into the pkgpath.
   246  		pkg = ""
   247  	}
   248  
   249  	hpath = filepath.Join(pkgDir, pkg, filepath.Base(filename))
   250  
   251  	if mainModulePath != "" {
   252  		relhpath, err := filepath.Rel(mainModulePath, hpath)
   253  		if err == nil {
   254  			hpath = "./" + relhpath
   255  		}
   256  	}
   257  
   258  	return hpath, hfn
   259  }
   260  

View as plain text