Tags: #go #errors #logs
// NOTE: This is code I implemented in the open-source github.com/fastly/cli repo.
package errors
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"text/template"
"time"
"github.com/fastly/go-fastly/v5/fastly"
)
// LogPath is the location of the fastly CLI error log.
var LogPath = func() string {
if dir, err := os.UserConfigDir(); err == nil {
return filepath.Join(dir, "fastly", "errors.log")
}
if dir, err := os.UserHomeDir(); err == nil {
return filepath.Join(dir, ".fastly", "errors.log")
}
panic("unable to deduce user config dir or user home dir")
}()
// LogInterface represents the LogEntries behaviours.
type LogInterface interface {
Add(err error)
AddWithContext(err error, ctx map[string]interface{})
Persist(logPath string, args []string) error
}
// Log is the primary interface for consumers.
var Log = new(LogEntries)
// LogEntries represents a list of recorded log entries.
type LogEntries []LogEntry
// Add adds a new log entry.
func (l *LogEntries) Add(err error) {
logMutex.Lock()
*l = append(*l, createLogEntry(err))
logMutex.Unlock()
}
// AddWithContext adds a new log entry with extra contextual data.
func (l *LogEntries) AddWithContext(err error, ctx map[string]interface{}) {
le := createLogEntry(err)
le.Context = ctx
logMutex.Lock()
*l = append(*l, le)
logMutex.Unlock()
}
// Persist persists recorded log entries to disk.
func (l LogEntries) Persist(logPath string, args []string) error {
if len(l) == 0 {
return nil
}
errMsg := "error accessing audit log file: %w"
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf(errMsg, err)
}
if fi, err := f.Stat(); err == nil {
if fi.Size() >= FileRotationSize {
f.Close()
f, err = os.Create(logPath)
if err != nil {
return fmt.Errorf(errMsg, err)
}
}
}
// G307 (CWE-): Deferring unsafe method "*os.File" on type "Close".
// gosec flagged this:
// Disabling because this file isn't critical to the functioning of the CLI
// and we only attempt to close it at the end of the user's execution flow.
/* #nosec */
defer f.Close()
cmd := "\nCOMMAND:\nfastly " + strings.Join(args, " ") + "\n\n"
if _, err := f.Write([]byte(cmd)); err != nil {
return err
}
record := `TIMESTAMP:
{{.Time}}
ERROR:
{{.Err}}
{{ range $key, $value := .Caller }}
{{ $key }}:
{{ $value }}
{{ end }}
{{ range $key, $value := .Context }}
{{ $key }}: {{ $value }}
{{ end }}
`
t := template.Must(template.New("record").Parse(record))
for _, entry := range l {
err := t.Execute(f, entry)
if err != nil {
return err
}
}
f.Write([]byte("------------------------------\n\n"))
return nil
}
// createLogEntry generates the boilerplate of a LogEntry.
func createLogEntry(err error) LogEntry {
le := LogEntry{
Time: Now(),
Err: err,
}
_, file, line, ok := runtime.Caller(2)
if ok {
le.Caller = map[string]interface{}{
"FILE": file[strings.Index(file, "/pkg/"):],
"LINE": line,
}
}
return le
}
// LogEntry represents a single error log entry.
type LogEntry struct {
Time time.Time
Err error
Caller map[string]interface{}
Context map[string]interface{}
}
// Caller represents where an error occurred.
type Caller struct {
File string
Line int
}
// Appending to a slice isn't threadsafe, and although we currently don't
// expect this to be a problem we can't predict future logic requirements that
// might result in more asynchronous operations, so we play it safe and utilise
// a lock before updating the LogEntries.
var logMutex sync.Mutex
// Now is exposed so that we may mock it from our test file.
//
// NOTE: The ideal way to deal with time is to inject it as a dependency and
// then the caller can provide a stubbed value, but in this case we don't want
// to have the CLI's business logic littered with lots of calls to time.Now()
// when that call can be handled internally by the .Add() method.
var Now = time.Now
// FileRotationSize represents the size the log file needs to be before we
// truncate it.
//
// NOTE: To enable easier testing of the log rotation logic, we don't define
// this as a constant but as a variable so the test file can mutate the value
// to something much smaller, meaning we can commit a small test file as part
// of the testing logic that will trigger a 'over the threshold' scenario.
var FileRotationSize int64 = 5242880 // 5mb