diff --git a/README.md b/README.md index aefb3a3..7781df5 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Example configuration: [example.jlv.jsonc](example.jlv.jsonc). ### Time Formats JSON Log Viewer can handle a variety of datetime formats when parsing your logs. +The value is formatted by default in the "[RFC3339](https://www.rfc-editor.org/rfc/rfc3339)" format. The format is configurable, see the `time_format` field in the [config](example.jlv.jsonc). #### `time` This will return the exact value that was set in the JSON document. diff --git a/example.jlv.jsonc b/example.jlv.jsonc index 2e3257e..8599b51 100644 --- a/example.jlv.jsonc +++ b/example.jlv.jsonc @@ -20,7 +20,19 @@ "$.t", "$.ts" ], - "width": 30 + "width": 30, + // Year: "2006" "06" + // Month: "Jan" "January" "01" "1" + // Day of the week: "Mon" "Monday" + // Day of the month: "2" "_2" "02" + // Day of the year: "__2" "002" + // Hour: "15" "3" "03" (PM or AM) + // Minute: "4" "04" + // Second: "5" "05" + // AM/PM mark: "PM" + // + // More details: https://go.dev/src/time/format.go. + "time_format": "2006-01-02T15:04:05Z07:00" }, { "title": "Level", diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 52f4748..6360386 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -6,12 +6,16 @@ import ( "fmt" "os" "strconv" + "time" units "github.com/docker/go-units" "github.com/go-playground/validator/v10" "github.com/hedhyw/jsoncjson" ) +// DefaultTimeFormat is a time format used in formatting timestamps by default. +const DefaultTimeFormat = time.RFC3339 + // PathDefault is a fake path to the default config. const PathDefault = "default" @@ -49,10 +53,13 @@ type Field struct { Kind FieldKind `json:"kind" validate:"required,oneof=time message numerictime secondtime millitime microtime level any"` References []string `json:"ref" validate:"min=1,dive,required"` Width int `json:"width" validate:"min=0"` + TimeFormat *string `json:"time_format,omitempty"` } // GetDefaultConfig returns the configuration with default values. func GetDefaultConfig() *Config { + defaultTimeFormat := DefaultTimeFormat + // nolint: mnd // Default config. return &Config{ Path: "default", @@ -63,6 +70,7 @@ func GetDefaultConfig() *Config { Kind: FieldKindNumericTime, References: []string{"$.timestamp", "$.time", "$.t", "$.ts"}, Width: 30, + TimeFormat: &defaultTimeFormat, }, { Title: "Level", Kind: FieldKindLevel, diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index ccb5813..78decea 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -112,7 +112,8 @@ func ExampleGetDefaultConfig() { // "$.t", // "$.ts" // ], - // "width": 30 + // "width": 30, + // "time_format": "2006-01-02T15:04:05Z07:00" // }, // { // "title": "Level", diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go index 6af9f71..7c67e00 100644 --- a/internal/pkg/source/entry.go +++ b/internal/pkg/source/entry.go @@ -16,6 +16,12 @@ import ( "github.com/hedhyw/json-log-viewer/internal/pkg/config" ) +const ( + unitSeconds = "s" + unitMilli = "ms" + unitMicro = "us" +) + // LazyLogEntry holds unredenred LogEntry. Use `LogEntry` getter. type LazyLogEntry struct { offset int64 @@ -129,7 +135,7 @@ func parseField( unquotedField = string(jsonField) } - return formatField(unquotedField, field.Kind, cfg) + return formatField(unquotedField, field, cfg) } return "-" @@ -138,11 +144,18 @@ func parseField( //nolint:cyclop // The cyclomatic complexity here is so high because of the number of FieldKinds. func formatField( value string, - kind config.FieldKind, + field config.Field, cfg *config.Config, ) string { + kind := field.Kind value = strings.TrimSpace(value) + timeFormat := config.DefaultTimeFormat + + if field.TimeFormat != nil { + timeFormat = *field.TimeFormat + } + // Numeric time attempts to infer the duration based on the length of the string if kind == config.FieldKindNumericTime { kind = guessTimeFieldKind(value) @@ -156,11 +169,11 @@ func formatField( case config.FieldKindTime: return formatMessage(value) case config.FieldKindSecondTime: - return formatMessage(formatTimeString(value, "s")) + return formatMessage(formatTimeValue(value, unitSeconds, timeFormat)) case config.FieldKindMilliTime: - return formatMessage(formatTimeString(value, "ms")) + return formatMessage(formatTimeValue(value, unitMilli, timeFormat)) case config.FieldKindMicroTime: - return formatMessage(formatTimeString(value, "us")) + return formatMessage(formatTimeValue(value, unitMicro, timeFormat)) case config.FieldKindAny: return formatMessage(value) default: @@ -262,11 +275,11 @@ func guessTimeFieldKind(timeStr string) config.FieldKind { } } -func formatTimeString(timeStr string, unit string) string { - duration, err := time.ParseDuration(timeStr + unit) +func formatTimeValue(timeValue string, unit string, format string) string { + duration, err := time.ParseDuration(timeValue + unit) if err != nil { - return timeStr + return timeValue } - return time.UnixMilli(0).Add(duration).UTC().Format(time.RFC3339) + return time.UnixMilli(0).Add(duration).UTC().Format(format) } diff --git a/internal/pkg/source/entry_test.go b/internal/pkg/source/entry_test.go index 41dd019..f20b942 100644 --- a/internal/pkg/source/entry_test.go +++ b/internal/pkg/source/entry_test.go @@ -339,11 +339,9 @@ func TestLazyLogEntriesFilter(t *testing.T) { func TestSecondTimeFormatting(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKindSecondTime) - expectedOutput := time.Unix(1, 0).UTC().Format(time.RFC3339) - secondsTestCases := [...]TimeFormattingTestCase{{ + secondsTestCases := [...]timeFormattingTestCase{{ TestName: "Seconds (float)", JSON: `{"timestamp":1.0}`, ExpectedOutput: expectedOutput, @@ -369,6 +367,8 @@ func TestSecondTimeFormatting(t *testing.T) { t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() + cfg := getTimestampFormattingConfig(config.FieldKindSecondTime, testCase.Format) + actual := parseTableRow(t, testCase.JSON, cfg) assert.Equal(t, testCase.ExpectedOutput, actual[0]) }) @@ -378,11 +378,9 @@ func TestSecondTimeFormatting(t *testing.T) { func TestMillisecondTimeFormatting(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKindMilliTime) - expectedOutput := time.Unix(2, 0).UTC().Format(time.RFC3339) - millisecondTestCases := [...]TimeFormattingTestCase{{ + millisecondTestCases := [...]timeFormattingTestCase{{ TestName: "Milliseconds (float)", JSON: `{"timestamp":2000.0}`, ExpectedOutput: expectedOutput, @@ -404,6 +402,8 @@ func TestMillisecondTimeFormatting(t *testing.T) { t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() + cfg := getTimestampFormattingConfig(config.FieldKindMilliTime, testCase.Format) + actual := parseTableRow(t, testCase.JSON, cfg) assert.Equal(t, testCase.ExpectedOutput, actual[0]) }) @@ -413,11 +413,9 @@ func TestMillisecondTimeFormatting(t *testing.T) { func TestMicrosecondTimeFormatting(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKindMicroTime) - expectedOutput := time.Unix(4, 0).UTC().Format(time.RFC3339) - microsecondTestCases := [...]TimeFormattingTestCase{{ + microsecondTestCases := [...]timeFormattingTestCase{{ TestName: "Microseconds (float)", JSON: `{"timestamp":4000000.0}`, ExpectedOutput: expectedOutput, @@ -439,6 +437,8 @@ func TestMicrosecondTimeFormatting(t *testing.T) { t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() + cfg := getTimestampFormattingConfig(config.FieldKindMicroTime, testCase.Format) + actual := parseTableRow(t, testCase.JSON, cfg) assert.Equal(t, testCase.ExpectedOutput, actual[0]) }) @@ -448,7 +448,7 @@ func TestMicrosecondTimeFormatting(t *testing.T) { func TestFormattingUnknown(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKind("unknown")) + cfg := getTimestampFormattingConfig(config.FieldKind("unknown"), config.DefaultTimeFormat) actual := parseTableRow(t, `{"timestamp": 1}`, cfg) assert.Equal(t, "1", actual[0]) @@ -457,7 +457,7 @@ func TestFormattingUnknown(t *testing.T) { func TestFormattingAny(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKindAny) + cfg := getTimestampFormattingConfig(config.FieldKindAny, config.DefaultTimeFormat) actual := parseTableRow(t, `{"timestamp": 1}`, cfg) assert.Equal(t, "1", actual[0]) @@ -466,9 +466,7 @@ func TestFormattingAny(t *testing.T) { func TestNumericKindTimeFormatting(t *testing.T) { t.Parallel() - cfg := getTimestampFormattingConfig(config.FieldKindNumericTime) - - numericKindCases := [...]TimeFormattingTestCase{{ + numericKindCases := [...]timeFormattingTestCase{{ TestName: "Date passthru", JSON: `{"timestamp":"2023-10-08 20:00:00"}`, ExpectedOutput: "2023-10-08 20:00:00", @@ -522,6 +520,8 @@ func TestNumericKindTimeFormatting(t *testing.T) { t.Run(testCase.TestName, func(t *testing.T) { t.Parallel() + cfg := getTimestampFormattingConfig(config.FieldKindNumericTime, testCase.Format) + actual := parseTableRow(t, testCase.JSON, cfg) assert.Equal(t, testCase.ExpectedOutput, actual[0]) }) @@ -613,6 +613,56 @@ func TestLazyLogEntryLogEntry(t *testing.T) { }) } +func TestTimeFormat(t *testing.T) { + t.Parallel() + + logDate := time.Date( + 2000, // Year. + time.January, + 2, // Day. + 3, // Hour. + 4, // Minutes. + 5, // Seconds. + 0, // Nanoseconds. + time.UTC, + ) + + jsonContent := fmt.Sprintf(`{"timestamp":"%d"}`, logDate.Unix()) + + numericKindCases := [...]timeFormattingTestCase{{ + TestName: "RFC3339", + JSON: jsonContent, + ExpectedOutput: logDate.Format(time.RFC3339), + Format: time.RFC3339, + }, { + TestName: "RFC1123", + JSON: jsonContent, + ExpectedOutput: logDate.Format(time.RFC1123), + Format: time.RFC1123, + }, { + TestName: "TimeOnly", + JSON: jsonContent, + ExpectedOutput: logDate.Format(time.TimeOnly), + Format: time.TimeOnly, + }, { + TestName: "TimeOnly", + JSON: jsonContent, + ExpectedOutput: "invalid", + Format: "invalid", + }} + + for _, testCase := range numericKindCases { + t.Run(testCase.TestName, func(t *testing.T) { + t.Parallel() + + cfg := getTimestampFormattingConfig(config.FieldKindSecondTime, testCase.Format) + + actual := parseTableRow(t, testCase.JSON, cfg) + assert.Equal(t, testCase.ExpectedOutput, actual[0]) + }) + } +} + func parseLazyLogEntry(tb testing.TB, value string, cfg *config.Config) source.LazyLogEntry { tb.Helper() @@ -653,20 +703,28 @@ func getFieldKindToValue(cfg *config.Config, entries []string) map[config.FieldK return fieldKindToValue } -type TimeFormattingTestCase struct { +type timeFormattingTestCase struct { TestName string JSON string ExpectedOutput string + Format string } -func getTimestampFormattingConfig(fieldKind config.FieldKind) *config.Config { +func getTimestampFormattingConfig(fieldKind config.FieldKind, format string) *config.Config { cfg := config.GetDefaultConfig() + var timeFormat *string + + if format != "" { + timeFormat = &format + } + cfg.Fields = []config.Field{{ Title: "Time", Kind: fieldKind, References: []string{"$.timestamp", "$.time", "$.t", "$.ts"}, Width: 30, + TimeFormat: timeFormat, }} return cfg