diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8afb47..05b7e532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Changelog +### 12.105.0 + +* `[log]` Added JSON output format +* `[log]` Added caller info to messages +* `[strutil]` Added method `IndexByteSkip` +* `[timeutil]` Added method `FromISOWeek` +* `[log]` Code refactoring + ### 12.104.0 * `[knf/united]` Added validation using `knf` validators diff --git a/ek.go b/ek.go index 4db43458..d4f40f41 100644 --- a/ek.go +++ b/ek.go @@ -20,7 +20,7 @@ import ( // ////////////////////////////////////////////////////////////////////////////////// // // VERSION is current ek package version -const VERSION = "12.104.0" +const VERSION = "12.105.0" // ////////////////////////////////////////////////////////////////////////////////// // diff --git a/knf/united/united.go b/knf/united/united.go index 3cd992b1..821b9013 100644 --- a/knf/united/united.go +++ b/knf/united/united.go @@ -1,4 +1,4 @@ -// Package united provides KNF configuration extended by enviroment variables and options +// Package united provides KNF configuration extended by environment variables and options package united // ////////////////////////////////////////////////////////////////////////////////// // diff --git a/log/example_test.go b/log/example_test.go index 2292256b..5f118c8e 100644 --- a/log/example_test.go +++ b/log/example_test.go @@ -38,8 +38,25 @@ func Example() { logger.Error("This is error message") logger.Crit("This is critical message") + // Enable colors for output + logger.UseColors = true + + // Encode messages to JSON + logger.UseJSON = true + + // Print caller info + logger.WithCaller = true + + // Use custom date & time layout + logger.TimeLayout = time.RFC3339 + + // Add fields to message + logger.Debug("This is %d %s message", 2, "debug", F{"user", "bob"}, F{"id", 200}) + // AUX message it's unskippable message which will be printed to log file with // any minimum level + // + // Note that all AUX messages are dropped when using JSON format logger.Aux("This is aux message") // Print simple divider diff --git a/log/log.go b/log/log.go index 4f1f37ac..5c8560dc 100644 --- a/log/log.go +++ b/log/log.go @@ -10,10 +10,13 @@ package log import ( "bufio" + "bytes" "errors" "fmt" "io" "os" + "runtime" + "strconv" "strings" "sync" "time" @@ -33,6 +36,11 @@ const ( AUX uint8 = 99 // AUX unskipable messages (separators, headers, etc...) ) +const ( + DATE_LAYOUT_TEXT = "2006/01/02 15:04:05.000" // Datetime layout for text logs + DATE_LAYOUT_JSON = time.RFC3339 // Datetime layout for JSON logs +) + // ////////////////////////////////////////////////////////////////////////////////// // // ILogger is interface for compatible loggers @@ -50,15 +58,19 @@ type ILogger interface { // Logger is a basic logger struct type Logger struct { - PrefixDebug bool // Prefix for debug messages - PrefixInfo bool // Prefix for info messages - PrefixWarn bool // Prefix for warning messages - PrefixError bool // Prefix for error messages - PrefixCrit bool // Prefix for critical error messages + PrefixDebug bool // Show prefix for debug messages + PrefixInfo bool // Show prefix for info messages + PrefixWarn bool // Show prefix for warning messages + PrefixError bool // Show prefix for error messages + PrefixCrit bool // Show prefix for critical/fatal messages - UseColors bool // Enable ANSI escape codes for colors in output + TimeLayout string // Date and time layout used for rendering dates + UseColors bool // Enable ANSI escape codes for colors in output + UseJSON bool // Encode messages to JSON + WithCaller bool // Show caller info file string + buf bytes.Buffer fd *os.File w *bufio.Writer mu *sync.Mutex @@ -67,6 +79,15 @@ type Logger struct { useBufIO bool } +// F is a shortcut for Field struct +type F = Field + +// Field contains key and value for JSON log +type Field struct { + Key string + Value any +} + // ////////////////////////////////////////////////////////////////////////////////// // // Global is global logger struct @@ -95,13 +116,10 @@ var Colors = map[uint8]string{ DEBUG: "{s-}", INFO: "", WARN: "{y}", - ERROR: "{r}", - CRIT: "{r*}", + ERROR: "{#208}", + CRIT: "{#196}{*}", } -// TimeFormat contains format string for time in logs -var TimeFormat = "2006/01/02 15:04:05.000" - // ////////////////////////////////////////////////////////////////////////////////// // // Errors @@ -126,6 +144,7 @@ var logLevelsNames = map[string]uint8{ "error": 3, "crit": 4, "critical": 4, + "fatal": 4, } // ////////////////////////////////////////////////////////////////////////////////// // @@ -273,7 +292,7 @@ func (l *Logger) MinLevel(level any) error { } // EnableBufIO enables buffered I/O support -func (l *Logger) EnableBufIO(interval time.Duration) { +func (l *Logger) EnableBufIO(flushInterval time.Duration) { if l == nil || l.mu == nil { return } @@ -287,7 +306,7 @@ func (l *Logger) EnableBufIO(interval time.Duration) { l.w = bufio.NewWriter(l.fd) } - go l.flushDaemon(interval) + go l.flushDaemon(flushInterval) } // Set changes logger output target @@ -338,29 +357,11 @@ func (l *Logger) Print(level uint8, f string, a ...any) error { return nil } - w := l.getWriter(level) - showPrefix := l.showPrefix(level) - - if f == "" || f[len(f)-1:] != "\n" { - f += "\n" - } - - var err error - - switch { - case l.UseColors && showPrefix: - color := strutil.B(fmtc.IsTag(Colors[level]), Colors[level], "") - _, err = fmt.Fprintf(w, fmtc.Render("{s-}%s{!} "+color+"%s %s{!}"), getTime(), PrefixMap[level], fmt.Sprintf(f, a...)) - case l.UseColors && !showPrefix: - color := strutil.B(fmtc.IsTag(Colors[level]), Colors[level], "") - _, err = fmt.Fprintf(w, fmtc.Render("{s-}%s{!} "+color+"%s{!}"), getTime(), fmt.Sprintf(f, a...)) - case !l.UseColors && showPrefix: - _, err = fmt.Fprintf(w, "%s %s %s", getTime(), PrefixMap[level], fmt.Sprintf(f, a...)) - case !l.UseColors && !showPrefix: - _, err = fmt.Fprintf(w, "%s %s", getTime(), fmt.Sprintf(f, a...)) + if l.UseJSON { + return l.writeJSON(level, f, a...) } - return err + return l.writeText(level, f, a...) } // Flush writes buffered data to file @@ -454,15 +455,137 @@ func (l *Logger) Is(level uint8) bool { // ////////////////////////////////////////////////////////////////////////////////// // +// String returns string representation of field +func (f Field) String() string { + return fmt.Sprintf("%s:%v", f.Key, f.Value) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// writeText writes text message into log +func (l *Logger) writeText(level uint8, f string, a ...any) error { + var color string + + w := l.getWriter(level) + + if l.UseColors { + color = strutil.B(fmtc.IsTag(Colors[level]), Colors[level], "") + } + + var err error + + l.buf.Reset() + + if l.UseColors { + fmtc.Fprintf(&l.buf, "{s}[ %s ]{!} ", l.formatDateTime(time.Now(), false)) + } else { + l.buf.WriteString("[ " + l.formatDateTime(time.Now(), false) + " ] ") + } + + if l.WithCaller { + if l.UseColors { + fmtc.Fprintf(&l.buf, "{s-}(%s){!} ", getCallerFromStack()) + } else { + l.buf.WriteString("(" + getCallerFromStack() + ") ") + } + } + + if l.isPrefixRequired(level) { + if l.UseColors { + fmtc.Fprintf(&l.buf, color+"{@}%s{!} ", PrefixMap[level]) + } else { + fmt.Fprintf(&l.buf, PrefixMap[level]+" ") + } + } + + operands, fields := splitPayload(a) + + if l.UseColors { + fmtc.Fprintf(&l.buf, color+f+"{!}", operands...) + } else { + fmt.Fprintf(&l.buf, f, operands...) + } + + if len(fields) > 0 { + l.buf.WriteRune(' ') + if l.UseColors { + fmtc.Fprint(&l.buf, "{b}"+fieldsToText(fields)+"{!}") + } else { + l.buf.WriteString(fieldsToText(fields)) + } + } + + if f == "" || f[len(f)-1:] != "\n" { + l.buf.WriteRune('\n') + } + + _, err = l.buf.WriteTo(w) + + l.buf.Reset() + + return err +} + +// writeJSON writes JSON encoded message into log +func (l *Logger) writeJSON(level uint8, msg string, a ...any) error { + // Aux ignored in JSON format + if level == AUX { + return nil + } + + if msg == "" && len(a) == 0 { + return nil + } + + l.buf.Reset() + l.buf.WriteRune('{') + + l.writeJSONLevel(level) + l.writeJSONTimestamp() + + if l.WithCaller { + l.buf.WriteString(`"caller":"` + getCallerFromStack() + `",`) + } + + operands, fields := splitPayload(a) + + if msg != "" { + if len(operands) > 0 { + l.buf.WriteString(`"msg":` + strconv.Quote(fmt.Sprintf(msg, operands...))) + } else { + l.buf.WriteString(`"msg":` + strconv.Quote(msg)) + } + } + + if len(fields) != 0 { + l.buf.WriteRune(',') + l.writeJSONFields(fields) + } + + l.buf.WriteRune('}') + l.buf.WriteRune('\n') + + _, err := l.buf.WriteTo(l.getWriter(level)) + + l.buf.Reset() + + return err +} + +// getWriter returns writer based on logger configuration func (l *Logger) getWriter(level uint8) io.Writer { var w io.Writer if l.fd == nil { - switch level { - case ERROR, CRIT: - w = os.Stderr - default: + if l.UseJSON { w = os.Stdout + } else { + switch level { + case ERROR, CRIT: + w = os.Stderr + default: + w = os.Stdout + } } } else { if l.w != nil { @@ -475,7 +598,20 @@ func (l *Logger) getWriter(level uint8) io.Writer { return w } -func (l *Logger) showPrefix(level uint8) bool { +// formatDateTime applies logger datetime layout for given date +func (l *Logger) formatDateTime(t time.Time, isJSON bool) string { + switch { + case l.TimeLayout == "" && isJSON: + return t.Format(DATE_LAYOUT_JSON) + case l.TimeLayout == "" && !isJSON: + return t.Format(DATE_LAYOUT_TEXT) + } + + return t.Format(l.TimeLayout) +} + +// isPrefixRequired returns true if prefix must be shown +func (l *Logger) isPrefixRequired(level uint8) bool { switch { case level == DEBUG && l.PrefixDebug, level == INFO && l.PrefixInfo, @@ -488,6 +624,7 @@ func (l *Logger) showPrefix(level uint8) bool { return false } +// flushDaemon periodically flashes buffered data func (l *Logger) flushDaemon(interval time.Duration) { for range time.NewTicker(interval).C { l.Flush() @@ -496,49 +633,125 @@ func (l *Logger) flushDaemon(interval time.Duration) { // ////////////////////////////////////////////////////////////////////////////////// // -func getTime() string { - return "[ " + time.Now().Format(TimeFormat) + " ]" +// writeJSONLevel writes level JSON into buffer +func (l *Logger) writeJSONLevel(level uint8) { + switch level { + case DEBUG: + l.buf.WriteString(`"level":"debug",`) + case INFO: + l.buf.WriteString(`"level":"info",`) + case WARN: + l.buf.WriteString(`"level":"warn",`) + case ERROR: + l.buf.WriteString(`"level":"error",`) + case CRIT: + l.buf.WriteString(`"level":"fatal",`) + } } +// writeJSONTimestamp writes timestamp JSON into buffer +func (l *Logger) writeJSONTimestamp() { + l.buf.WriteString(`"ts":`) + + if l.TimeLayout == "" { + l.buf.WriteString(strconv.FormatFloat(float64(time.Now().UnixMicro())/1000000, 'f', -1, 64)) + } else { + l.buf.WriteRune('"') + l.buf.WriteString(l.formatDateTime(time.Now(), true)) + l.buf.WriteRune('"') + } + + l.buf.WriteRune(',') +} + +// writeJSONFields writes fields JSON into buffer +func (l *Logger) writeJSONFields(fields []any) { + for i, f := range fields { + switch t := f.(type) { + case Field: + l.writeJSONField(t) + + if i+1 != len(fields) { + l.buf.WriteRune(',') + } + } + } +} + +// writeJSONField writes field JSON into buffer +func (l *Logger) writeJSONField(field Field) { + l.buf.WriteString(strconv.Quote(field.Key) + ":") + + switch t := field.Value.(type) { + case string: + l.buf.WriteString(strconv.Quote(t)) + + case bool: + l.buf.WriteString(strconv.FormatBool(t)) + + case int: + l.buf.WriteString(strconv.Itoa(t)) + case int8: + l.buf.WriteString(strconv.FormatInt(int64(t), 10)) + case int16: + l.buf.WriteString(strconv.FormatInt(int64(t), 10)) + case int32: + l.buf.WriteString(strconv.FormatInt(int64(t), 10)) + case int64: + l.buf.WriteString(strconv.FormatInt(t, 10)) + + case uint: + l.buf.WriteString(strconv.FormatUint(uint64(t), 10)) + case uint8: + l.buf.WriteString(strconv.FormatUint(uint64(t), 10)) + case uint16: + l.buf.WriteString(strconv.FormatUint(uint64(t), 10)) + case uint32: + l.buf.WriteString(strconv.FormatUint(uint64(t), 10)) + case uint64: + l.buf.WriteString(strconv.FormatUint(t, 10)) + + case float32: + l.buf.WriteString(strconv.FormatFloat(float64(t), 'f', -1, 32)) + case float64: + l.buf.WriteString(strconv.FormatFloat(t, 'f', -1, 64)) + + case time.Duration: + l.buf.WriteString(strconv.FormatFloat(t.Seconds(), 'f', -1, 64)) + case time.Time: + l.buf.WriteString(strconv.Quote(l.formatDateTime(t, true))) + + default: + l.buf.WriteString(strconv.Quote(fmt.Sprintf("%v", field.Value))) + } +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +// convertMinLevelValue converts any supported format of minimal level to uint8 used +// for default levels func convertMinLevelValue(level any) (uint8, error) { switch u := level.(type) { - case int: return uint8(u), nil - case int8: return uint8(u), nil - case int16: return uint8(u), nil - case int32: return uint8(u), nil - case int64: return uint8(u), nil - case uint: return uint8(u), nil - case uint8: return uint8(u), nil - case uint16: return uint8(u), nil - case uint32: return uint8(u), nil - case uint64: return uint8(u), nil - - case float32: - return uint8(u), nil - - case float64: - return uint8(u), nil - case string: code, ok := logLevelsNames[strings.ToLower(level.(string))] @@ -551,3 +764,84 @@ func convertMinLevelValue(level any) (uint8, error) { return 255, ErrUnexpectedLevel } + +// fieldsToText converts fields slice to string +func fieldsToText(fields []any) string { + var result string + + result += "{" + + for i, f := range fields { + field := f.(Field) + + result += fmt.Sprintf("%s: %v", field.Key, field.Value) + + if i+1 != len(fields) { + result += " | " + } + } + + result += "}" + + return result +} + +// splitPayload split mixed payload to format string operands and fields +func splitPayload(payload []any) ([]any, []any) { + firstField := -1 + + for i, p := range payload { + switch p.(type) { + case Field: + if firstField < 0 { + firstField = i + } + default: + if firstField > 0 { + payload[firstField], payload[i] = payload[i], payload[firstField] + firstField++ + } + } + } + + if firstField == -1 { + return payload, nil + } + + return payload[:firstField], payload[firstField:] +} + +// getCallerFromStack returns caller function and line from stack +func getCallerFromStack() string { + pcs := make([]uintptr, 64) + n := runtime.Callers(2, pcs) + + file := "" + frames := runtime.CallersFrames(pcs[:n]) + + for { + frame, more := frames.Next() + + if !more { + break + } + + if file == "" { + file = frame.File + } + + if file == frame.File { + continue + } + + return extractCallerFromFrame(frame) + } + + return "unknown" +} + +// extractCallerFromFrame extracts caller info from frame +func extractCallerFromFrame(f runtime.Frame) string { + index := strutil.IndexByteSkip(f.File, '/', -1) + return f.File[index+1:] + ":" + strconv.Itoa(f.Line) +} diff --git a/log/log_test.go b/log/log_test.go index 958230f4..0656fdaf 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -8,6 +8,7 @@ package log // ////////////////////////////////////////////////////////////////////////////////// // import ( + "encoding/json" "os" "strings" "sync" @@ -26,6 +27,15 @@ type LogSuite struct { TempDir string } +type JSONRecord struct { + Level string `json:"level"` + TS string `json:"ts"` + Caller string `json:"caller"` + Msg string `json:"msg"` + ID int `json:"id"` + User string `json:"user"` +} + // ////////////////////////////////////////////////////////////////////////////////// // func Test(t *testing.T) { TestingT(t) } @@ -108,6 +118,8 @@ func (ls *LogSuite) TestErrors(c *C) { c.Assert(err, NotNil) c.Assert(err, DeepEquals, ErrNilLogger) + c.Assert(l.Is(DEBUG), Equals, false) + err = l.Set("", 0) c.Assert(err, NotNil) @@ -141,6 +153,7 @@ func (ls *LogSuite) TestLevel(c *C) { c.Assert(l.MinLevel("error"), IsNil) c.Assert(l.MinLevel("crit"), IsNil) c.Assert(l.MinLevel("critical"), IsNil) + c.Assert(l.MinLevel("fatal"), IsNil) c.Assert(l.MinLevel(int8(1)), IsNil) c.Assert(l.MinLevel(int16(1)), IsNil) c.Assert(l.MinLevel(int32(1)), IsNil) @@ -150,8 +163,6 @@ func (ls *LogSuite) TestLevel(c *C) { c.Assert(l.MinLevel(uint16(1)), IsNil) c.Assert(l.MinLevel(uint32(1)), IsNil) c.Assert(l.MinLevel(uint64(1)), IsNil) - c.Assert(l.MinLevel(float32(1)), IsNil) - c.Assert(l.MinLevel(float64(1)), IsNil) c.Assert(l.MinLevel("abcd"), NotNil) c.Assert(l.MinLevel(time.Now()), NotNil) @@ -190,11 +201,11 @@ func (ls *LogSuite) TestStdOutput(c *C) { l := &Logger{mu: &sync.Mutex{}} - err = l.Print(INFO, "info") + err = l.Print(INFO, "Info message") c.Assert(err, IsNil) - err = l.Print(ERROR, "error") + err = l.Print(ERROR, "Error message") c.Assert(err, IsNil) @@ -203,19 +214,23 @@ func (ls *LogSuite) TestStdOutput(c *C) { l.UseColors = true l.PrefixError = true - err = l.Print(INFO, "info") + err = l.Print(INFO, "Info message") c.Assert(err, IsNil) - err = l.Print(ERROR, "error") + err = l.Print(ERROR, "Error message") c.Assert(err, IsNil) fmtc.DisableColors = false + + l.UseJSON = true + + err = l.Print(ERROR, "Error message") } func (ls *LogSuite) TestWithoutPrefixes(c *C) { - logfile := ls.TempDir + "/file1.log" + logfile := ls.TempDir + "/basic.log" l, err := New(logfile, 0644) l.MinLevel(DEBUG) @@ -286,7 +301,7 @@ func (ls *LogSuite) TestWithoutPrefixes(c *C) { } func (ls *LogSuite) TestWithPrefixes(c *C) { - logfile := ls.TempDir + "/file2.log" + logfile := ls.TempDir + "/prefixes.log" err := Set(logfile, 0644) MinLevel(DEBUG) @@ -358,8 +373,152 @@ func (ls *LogSuite) TestWithPrefixes(c *C) { c.Assert(dataSlice[18][28:], Equals, "--------------------------------------------------------------------------------") } +func (ls *LogSuite) TestJSON(c *C) { + logfile := ls.TempDir + "/json.log" + l, err := New(logfile, 0644) + + l.MinLevel(DEBUG) + + c.Assert(err, IsNil) + c.Assert(l, Not(IsNil)) + + l.UseJSON = true + l.WithCaller = true + l.TimeLayout = time.RFC822 + + c.Assert(fsutil.GetMode(logfile), Equals, os.FileMode(0644)) + + c.Assert(l.Print(DEBUG, "Test debug %d (%s)", DEBUG, F{"id", 101}, "test1", F{"user", "john"}), IsNil) + c.Assert(l.Print(INFO, "Test info %d", INFO, F{"id", 102}, F{"user", "bob"}), IsNil) + c.Assert(l.Print(WARN, "Test warn %d", WARN, F{"id", 103}, F{"user", "frida"}), IsNil) + c.Assert(l.Print(ERROR, "Test error %d", ERROR, F{"id", 104}, F{"user", "alisa"}), IsNil) + c.Assert(l.Print(CRIT, "Test crit %d", CRIT, F{"id", 105}, F{"user", "simon"}), IsNil) + + c.Assert(l.Info("Test message"), IsNil) + + l.TimeLayout = "" + + c.Assert(l.Info("Test message"), IsNil) + + l.Print(DEBUG, "") + l.Aux("Test") + + data, err := os.ReadFile(logfile) + + c.Assert(err, IsNil) + c.Assert(len(data), Not(Equals), 0) + + dataSlice := strings.Split(string(data), "\n") + + c.Assert(len(dataSlice), Equals, 8) + + records := parseJSONRecords(dataSlice) + + c.Assert(len(records), Equals, 7) + + c.Assert(records[0].Level, Equals, "debug") + c.Assert(records[0].TS, Not(Equals), "") + c.Assert(records[0].Caller, Not(Equals), "") + c.Assert(records[0].Msg, Equals, "Test debug 0 (test1)") + c.Assert(records[0].ID, Equals, 101) + c.Assert(records[0].User, Equals, "john") + + c.Assert(records[1].Level, Equals, "info") + c.Assert(records[1].TS, Not(Equals), "") + c.Assert(records[1].Caller, Not(Equals), "") + c.Assert(records[1].Msg, Equals, "Test info 1") + c.Assert(records[1].ID, Equals, 102) + c.Assert(records[1].User, Equals, "bob") + + c.Assert(records[2].Level, Equals, "warn") + c.Assert(records[2].TS, Not(Equals), "") + c.Assert(records[2].Caller, Not(Equals), "") + c.Assert(records[2].Msg, Equals, "Test warn 2") + c.Assert(records[2].ID, Equals, 103) + c.Assert(records[2].User, Equals, "frida") + + c.Assert(records[3].Level, Equals, "error") + c.Assert(records[3].TS, Not(Equals), "") + c.Assert(records[3].Caller, Not(Equals), "") + c.Assert(records[3].Msg, Equals, "Test error 3") + c.Assert(records[3].ID, Equals, 104) + c.Assert(records[3].User, Equals, "alisa") + + c.Assert(records[4].Level, Equals, "fatal") + c.Assert(records[4].TS, Not(Equals), "") + c.Assert(records[4].Caller, Not(Equals), "") + c.Assert(records[4].Msg, Equals, "Test crit 4") + c.Assert(records[4].ID, Equals, 105) + c.Assert(records[4].User, Equals, "simon") + + c.Assert(records[5].Level, Equals, "info") + c.Assert(records[5].TS, Not(Equals), "") + c.Assert(records[5].Caller, Not(Equals), "") + c.Assert(records[5].Msg, Equals, "Test message") + c.Assert(records[5].ID, Equals, 0) + c.Assert(records[5].User, Equals, "") +} + +func (ls *LogSuite) TestWithCaller(c *C) { + logfile := ls.TempDir + "/caller.log" + l, err := New(logfile, 0644) + + c.Assert(err, IsNil) + + l.WithCaller = true + + c.Assert(l.Print(INFO, "Test info 1"), IsNil) + + l.UseColors = true + fmtc.DisableColors = true + + c.Assert(l.Print(INFO, "Test info 2"), IsNil) + + fmtc.DisableColors = false + + data, err := os.ReadFile(logfile) + + c.Assert(err, IsNil) + c.Assert(len(data), Not(Equals), 0) + + dataSlice := strings.Split(string(data), "\n") + + c.Assert(len(dataSlice), Equals, 3) + + c.Assert(dataSlice[0][28:], Equals, "(log/log_test.go:470) Test info 1") + c.Assert(dataSlice[1][28:], Equals, "(log/log_test.go:475) Test info 2") +} + +func (ls *LogSuite) TestWithFields(c *C) { + logfile := ls.TempDir + "/fields.log" + l, err := New(logfile, 0644) + + c.Assert(err, IsNil) + + c.Assert(l.Info("Test info %d", 1, F{"name", "john"}, F{"id", 1}), IsNil) + + l.UseColors = true + fmtc.DisableColors = true + + c.Assert(l.Info("Test info %d", 2, F{"name", "john"}, F{"id", 1}), IsNil) + + fmtc.DisableColors = false + + data, err := os.ReadFile(logfile) + + c.Assert(err, IsNil) + c.Assert(len(data), Not(Equals), 0) + + dataSlice := strings.Split(string(data), "\n") + + c.Assert(len(dataSlice), Equals, 3) + + c.Assert(dataSlice[0][28:], Equals, "Test info 1 {name: john | id: 1}") + c.Assert(dataSlice[1][28:], Equals, "Test info 2 {name: john | id: 1}") +} + func (ls *LogSuite) TestBufIODaemon(c *C) { - logfile := ls.TempDir + "/file3.log" + logfile := ls.TempDir + "/bufio-daemon.log" err := Set(logfile, 0644) MinLevel(DEBUG) @@ -436,7 +595,7 @@ func (ls *LogSuite) TestBufIODaemon(c *C) { } func (ls *LogSuite) TestBufIO(c *C) { - logfile := ls.TempDir + "/file4.log" + logfile := ls.TempDir + "/bufio.log" err := Set(logfile, 0644) c.Assert(err, IsNil) @@ -486,8 +645,10 @@ func (ls *LogSuite) TestLoggerIsNil(c *C) { } func (ls *LogSuite) TestStdLogger(c *C) { + logfile := ls.TempDir + "/stdlogger.log" l := &Logger{mu: &sync.Mutex{}} - l.Set(ls.TempDir+"/file5.log", 0644) + + l.Set(logfile, 0644) std := &StdLogger{l} @@ -506,7 +667,7 @@ func (ls *LogSuite) TestStdLogger(c *C) { c.Assert(func() { std.Panicf("%s", "testPanic") }, PanicMatches, "testPanic") c.Assert(func() { std.Panicln("testPanic") }, PanicMatches, "testPanic\n") - data, err := os.ReadFile(ls.TempDir + "/file5.log") + data, err := os.ReadFile(logfile) if err != nil { c.Fatal(err) @@ -528,3 +689,146 @@ func (ls *LogSuite) TestNilLogger(c *C) { c.Assert(func() { l.Crit("test") }, NotPanics) c.Assert(func() { l.Print(0, "test") }, NotPanics) } + +func (ls *LogSuite) TestTimeLayout(c *C) { + l := &Logger{mu: &sync.Mutex{}} + t := time.Unix(1704067200, 0) + + jf := l.formatDateTime(t, true) + tf := l.formatDateTime(t, false) + + l.TimeLayout = time.Kitchen + cf := l.formatDateTime(t, false) + + c.Assert(jf, Not(Equals), "") + c.Assert(tf, Not(Equals), "") + c.Assert(cf, Not(Equals), "") + + c.Assert(jf != tf, Equals, true) + c.Assert(tf != cf, Equals, true) + c.Assert(jf != cf, Equals, true) +} + +func (ls *LogSuite) TestFields(c *C) { + f := F{"test", 123} + c.Assert(f.String(), Equals, "test:123") + + l := &Logger{mu: &sync.Mutex{}} + l.writeJSONField(F{"test", "abcd"}) + c.Assert(l.buf.String(), Equals, "\"test\":\"abcd\"") + l.buf.Reset() + + l.writeJSONField(F{"test", false}) + c.Assert(l.buf.String(), Equals, "\"test\":false") + l.buf.Reset() + + l.writeJSONField(F{"test", 123}) + c.Assert(l.buf.String(), Equals, "\"test\":123") + l.buf.Reset() + + l.writeJSONField(F{"test", int8(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", int8(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", int16(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", int32(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", int64(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", uint(123)}) + c.Assert(l.buf.String(), Equals, "\"test\":123") + l.buf.Reset() + + l.writeJSONField(F{"test", uint8(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", uint8(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", uint16(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", uint32(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", uint64(33)}) + c.Assert(l.buf.String(), Equals, "\"test\":33") + l.buf.Reset() + + l.writeJSONField(F{"test", float32(1.23)}) + c.Assert(l.buf.String(), Equals, "\"test\":1.23") + l.buf.Reset() + + l.writeJSONField(F{"test", float64(1.23)}) + c.Assert(l.buf.String(), Equals, "\"test\":1.23") + l.buf.Reset() + + l.writeJSONField(F{"test", time.Minute - (150 * time.Millisecond)}) + c.Assert(l.buf.String(), Equals, "\"test\":59.85") + l.buf.Reset() + + l.writeJSONField(F{"test", time.Now()}) + c.Assert(l.buf.String(), Not(Equals), "") + l.buf.Reset() + + l.writeJSONField(F{"test", []string{"A"}}) + c.Assert(l.buf.String(), Equals, "\"test\":\"[A]\"") + l.buf.Reset() +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +func (s *LogSuite) BenchmarkJSONWrite(c *C) { + l, err := New(os.DevNull, 0644) + + c.Assert(err, IsNil) + + l.UseJSON = true + + for i := 0; i < c.N; i++ { + l.Info("Test %s %s", "test", F{"test1", 1}, "abcd", F{"test2", false}) + } +} + +func (s *LogSuite) BenchmarkTextWrite(c *C) { + l, err := New(os.DevNull, 0644) + + c.Assert(err, IsNil) + + for i := 0; i < c.N; i++ { + l.Info("Test %s %s", "test", "abcd") + } +} + +// ////////////////////////////////////////////////////////////////////////////////// // + +func parseJSONRecords(data []string) []*JSONRecord { + var result []*JSONRecord + + for _, l := range data { + if l == "" { + continue + } + + r := &JSONRecord{} + json.Unmarshal([]byte(l), r) + result = append(result, r) + } + + return result +} diff --git a/strutil/example_test.go b/strutil/example_test.go index ce65ab4e..78a66a17 100644 --- a/strutil/example_test.go +++ b/strutil/example_test.go @@ -198,3 +198,15 @@ func ExampleHasSuffixAny() { // true // false } + +func ExampleIndexByteSkip() { + // Index from left + fmt.Println(IndexByteSkip("/home/john/projects/test.log", '/', 2)) + + // Index from right + fmt.Println(IndexByteSkip("/home/john/projects/test.log", '/', -1)) + + // Output: + // 10 + // 10 +} diff --git a/strutil/strutil.go b/strutil/strutil.go index fad6744c..c2ee9767 100644 --- a/strutil/strutil.go +++ b/strutil/strutil.go @@ -446,6 +446,42 @@ func HasSuffixAny(s string, suffix ...string) bool { return false } +// IndexByteSkip returns the index of the given byte in the string after skipping +// the first or the last N occurrences +func IndexByteSkip(s string, c byte, skip int) int { + if skip == 0 { + return strings.IndexByte(s, c) + } + + counter := 0 + + if skip > 0 { + for i := 0; i < len(s); i++ { + if s[i] == c { + counter++ + } + + if counter > skip { + return i + } + } + } else { + skip *= -1 + + for i := len(s) - 1; i > 0; i-- { + if s[i] == c { + counter++ + } + + if counter > skip { + return i + } + } + } + + return -1 +} + // ////////////////////////////////////////////////////////////////////////////////// // func appendField(data []string, item string) []string { diff --git a/strutil/strutil_test.go b/strutil/strutil_test.go index 6c82f755..a101df9c 100644 --- a/strutil/strutil_test.go +++ b/strutil/strutil_test.go @@ -200,6 +200,16 @@ func (s *StrUtilSuite) TestHasSuffixAny(c *C) { c.Assert(HasSuffixAny("abcd#", "$", "@"), Equals, false) } +func (s *StrUtilSuite) TestIndexByteSkip(c *C) { + c.Assert(IndexByteSkip("", '/', 0), Equals, -1) + c.Assert(IndexByteSkip("", '/', 1), Equals, -1) + c.Assert(IndexByteSkip("/", '/', 1), Equals, -1) + c.Assert(IndexByteSkip("/home/john/projects/test.log", '/', 2), Equals, 10) + c.Assert(IndexByteSkip("/home/john/projects/test.log", '/', -1), Equals, 10) +} + +// ////////////////////////////////////////////////////////////////////////////////// // + func (s *StrUtilSuite) BenchmarkSubstr(c *C) { for i := 0; i < c.N; i++ { Substr("test1234TEST", 4, 8) diff --git a/timeutil/example_test.go b/timeutil/example_test.go index 45b60de0..b4d0f784 100644 --- a/timeutil/example_test.go +++ b/timeutil/example_test.go @@ -106,6 +106,7 @@ func ExampleStartOfHour() { d := time.Date(2021, 6, 15, 12, 30, 15, 0, time.UTC) fmt.Println(StartOfHour(d)) + // Output: // 2021-06-15 12:00:00 +0000 UTC } @@ -114,6 +115,7 @@ func ExampleStartOfDay() { d := time.Date(2021, 6, 15, 12, 30, 15, 0, time.UTC) fmt.Println(StartOfDay(d)) + // Output: // 2021-06-15 00:00:00 +0000 UTC } @@ -122,6 +124,7 @@ func ExampleStartOfWeek() { d := time.Date(2021, 6, 15, 12, 30, 15, 0, time.UTC) fmt.Println(StartOfWeek(d, time.Monday)) + // Output: // 2021-06-14 00:00:00 +0000 UTC } @@ -130,6 +133,7 @@ func ExampleStartOfMonth() { d := time.Date(2021, 6, 15, 12, 30, 15, 0, time.UTC) fmt.Println(StartOfMonth(d)) + // Output: // 2021-06-01 00:00:00 +0000 UTC } @@ -138,6 +142,7 @@ func ExampleStartOfYear() { d := time.Date(2021, 6, 15, 12, 30, 15, 0, time.UTC) fmt.Println(StartOfYear(d)) + // Output: // 2021-01-01 00:00:00 +0000 UTC } @@ -146,6 +151,7 @@ func ExamplePrevDay() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(PrevDay(d)) + // Output: // 2021-05-31 12:30:15 +0000 UTC } @@ -154,6 +160,7 @@ func ExamplePrevMonth() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(PrevMonth(d)) + // Output: // 2021-05-01 12:30:15 +0000 UTC } @@ -162,6 +169,7 @@ func ExamplePrevYear() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(PrevYear(d)) + // Output: // 2020-06-01 12:30:15 +0000 UTC } @@ -170,6 +178,7 @@ func ExampleNextDay() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(NextDay(d)) + // Output: // 2021-06-02 12:30:15 +0000 UTC } @@ -178,6 +187,7 @@ func ExampleNextMonth() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(NextMonth(d)) + // Output: // 2021-07-01 12:30:15 +0000 UTC } @@ -186,6 +196,7 @@ func ExampleNextYear() { d := time.Date(2021, 6, 1, 12, 30, 15, 0, time.UTC) fmt.Println(NextYear(d)) + // Output: // 2022-06-01 12:30:15 +0000 UTC } @@ -194,6 +205,7 @@ func ExamplePrevWorkday() { d := time.Date(2021, 6, 6, 12, 30, 15, 0, time.UTC) fmt.Println(PrevWorkday(d)) + // Output: // 2021-06-04 12:30:15 +0000 UTC } @@ -202,6 +214,7 @@ func ExamplePrevWeekend() { d := time.Date(2021, 6, 6, 12, 30, 15, 0, time.UTC) fmt.Println(PrevWeekend(d)) + // Output: // 2021-06-05 12:30:15 +0000 UTC } @@ -210,6 +223,7 @@ func ExampleNextWorkday() { d := time.Date(2021, 6, 6, 12, 30, 15, 0, time.UTC) fmt.Println(NextWorkday(d)) + // Output: // 2021-06-07 12:30:15 +0000 UTC } @@ -218,10 +232,20 @@ func ExampleNextWeekend() { d := time.Date(2021, 6, 6, 12, 30, 15, 0, time.UTC) fmt.Println(NextWeekend(d)) + // Output: // 2021-06-12 12:30:15 +0000 UTC } +func ExampleFromISOWeek() { + t := FromISOWeek(25, 2021, time.UTC) + + fmt.Println(t) + + // Output: + // 2021-06-18 00:00:00 +0000 UTC +} + func ExampleDate_Unix() { StartDate = 1577836800 diff --git a/timeutil/timeutil.go b/timeutil/timeutil.go index a62dbadb..334c4ce5 100644 --- a/timeutil/timeutil.go +++ b/timeutil/timeutil.go @@ -378,6 +378,17 @@ func NextWeekend(t time.Time) time.Time { } } +// FromISOWeek returns date for given week number in given year +func FromISOWeek(week, year int, loc *time.Location) time.Time { + week = mathutil.Between(week, 1, 53) + + if year <= 0 { + year = time.Now().In(loc).Year() + } + + return time.Date(year, 1, 1, 0, 0, 0, 0, loc).AddDate(0, 0, 7*(week-1)) +} + // ////////////////////////////////////////////////////////////////////////////////// // // It's ok to have so long method here diff --git a/timeutil/timeutil_test.go b/timeutil/timeutil_test.go index 959d594c..9617f1a8 100644 --- a/timeutil/timeutil_test.go +++ b/timeutil/timeutil_test.go @@ -315,6 +315,11 @@ func (s *TimeUtilSuite) TestHelpers(c *C) { c.Assert(StartOfWeek(time.Time{}, time.Monday), DeepEquals, time.Time{}) c.Assert(StartOfMonth(d), DeepEquals, time.Date(2021, 8, 1, 0, 0, 0, 0, time.Local)) c.Assert(StartOfYear(d), DeepEquals, time.Date(2021, 1, 1, 0, 0, 0, 0, time.Local)) + + y := time.Now().In(time.Local).Year() + c.Assert(FromISOWeek(0, 0, time.Local), DeepEquals, time.Date(y, 1, 1, 0, 0, 0, 0, time.Local)) + c.Assert(FromISOWeek(100, 2021, time.Local), DeepEquals, time.Date(2021, 12, 31, 0, 0, 0, 0, time.Local)) + c.Assert(FromISOWeek(23, 2021, time.Local), DeepEquals, time.Date(2021, 6, 4, 0, 0, 0, 0, time.Local)) } // ////////////////////////////////////////////////////////////////////////////////// //