Skip to content

Commit

Permalink
feat: support human readable timestamps (#38)
Browse files Browse the repository at this point in the history
Co-authored-by: Maksym Kryvchun <hedhyw@yahoo.com>
  • Loading branch information
cosmastech and hedhyw committed Nov 10, 2023
1 parent c8ce24a commit e3aacf1
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 16 deletions.
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ Example configuration:
"title": "Time", // Max length is 32.
// Kind affects rendering. There are:
// * time;
// * numerictime;
// * secondtime;
// * millitime;
// * microtime;
// * level;
// * message;
// * any.
"kind": "time",
"kind": "numerictime",
"ref": [
// The application will display the first matched value.
"$.timestamp",
Expand Down Expand Up @@ -153,6 +157,28 @@ Example configuration:
}
```

### Time Formats
JSON Log Viewer can handle a variety of datetime formats when parsing your logs.

#### `time`
This will return the exact value that was set in the JSON document.

#### `numerictime`
This is a "smart" parser. It can accept an integer, a float, or a string. If it is numeric (`1234443`, `1234443.589`, `"1234443"`, `"1234443.589"`), based on the number of digits, it will parse as seconds, milliseconds, or microseconds. The output is a UTC-based RFC 3339 datetime.

If a string such as `"2023-05-01T12:00:34Z"` or `"---"` is used, the value will just be carried forward to your column.

If you find that the smart parsing is giving unwanted results or you need greater control over how a datetime is parsed, considered using one of the other time formats instead.

#### `secondtime`
This will attempt to parse the value as number of seconds and render as a UTC-based RFC 3339. Values accepted are integer, string, or float.

#### `millitime`
Similar to `secondtime`, this will attempt to parse the value as number of milliseconds. Values accepted are integer, string, or float.

#### `microtime`
Similar to `secondtime` and `millistime`, this will attempt to parse the value as number of microseconds. Values accepted are integer, string, or float.

## Resources

Alternatives:
Expand Down
8 changes: 8 additions & 0 deletions assets/example.log
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,11 @@
{"time":"1970-01-01T00:00:00.00","level":"DEBUG","message": "A house divided against itself cannot stand.","author": "Abraham Lincoln"}
{"time":"1970-01-01T00:00:00.00","level":"TRACE","message": "Difficulties increase the nearer we get to the goal.","author": "Johann Wolfgang von Goethe"}
plain text log
{"time":"12444334.222","level":"TRACE","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
{"time":12444334.222,"level":"INFO","message": "Laugh at the world's foolishness or weep over it, you will regret both","author": "Kierkegaard"}
{"time":12444334,"level":"WARN","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
{"time":"12444334","level":"DEBUG","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
{"time":124999444443,"level":"TRACE","message": "Money doesn't talk, it swears","author": "Bob Dylan"}
{"time":124999444443.45,"level":"WARN","message": "If a man knows not to which port he sails, no wind is favorable","author": "Seneca"}
{"time":"124999444443.45","level":"VERBOSE","message": "Begin at once to live, and count each separate day as a separate life","author": "Seneca"}
{"time":12499944444398.45,"level":"VERBOSE","message": "Begin at once to live, and count each separate day as a separate life","author": "Seneca"}
16 changes: 10 additions & 6 deletions internal/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ type FieldKind string

// Possible kinds.
const (
FieldKindTime FieldKind = "time"
FieldKindMessage FieldKind = "message"
FieldKindLevel FieldKind = "level"
FieldKindAny FieldKind = "any"
FieldKindTime FieldKind = "time"
FieldKindNumericTime FieldKind = "numerictime"
FieldKindSecondTime FieldKind = "secondtime"
FieldKindMilliTime FieldKind = "millitime"
FieldKindMicroTime FieldKind = "microtime"
FieldKindMessage FieldKind = "message"
FieldKindLevel FieldKind = "level"
FieldKindAny FieldKind = "any"
)

// Field customization.
type Field struct {
Title string `json:"title" validate:"required,min=1,max=32"`
Kind FieldKind `json:"kind" validate:"required,oneof=time message level any"`
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"`
}
Expand All @@ -46,7 +50,7 @@ func GetDefaultConfig() *Config {
Path: "default",
Fields: []Field{{
Title: "Time",
Kind: FieldKindTime,
Kind: FieldKindNumericTime,
References: []string{"$.timestamp", "$.time", "$.t", "$.ts"},
Width: 30,
}, {
Expand Down
26 changes: 25 additions & 1 deletion internal/pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func ExampleGetDefaultConfig() {
// "fields": [
// {
// "title": "Time",
// "kind": "time",
// "kind": "numerictime",
// "ref": [
// "$.timestamp",
// "$.time",
Expand Down Expand Up @@ -208,6 +208,30 @@ func TestValidateField(t *testing.T) {
value.Kind = config.FieldKindTime
},
IsValid: true,
}, {
Name: "kind_numeric_time",
Apply: func(value *config.Field) {
value.Kind = config.FieldKindNumericTime
},
IsValid: true,
}, {
Name: "kind_second_time",
Apply: func(value *config.Field) {
value.Kind = config.FieldKindSecondTime
},
IsValid: true,
}, {
Name: "kind_milli_time",
Apply: func(value *config.Field) {
value.Kind = config.FieldKindMilliTime
},
IsValid: true,
}, {
Name: "kind_micro_time",
Apply: func(value *config.Field) {
value.Kind = config.FieldKindMicroTime
},
IsValid: true,
}, {
Name: "unset_kind",
Apply: func(value *config.Field) {
Expand Down
55 changes: 54 additions & 1 deletion internal/pkg/source/entry.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"unicode"

"github.com/charmbracelet/bubbles/table"
Expand Down Expand Up @@ -79,8 +80,11 @@ func parseField(parsedLine any, field config.Field) string {
}

unquotedField, err := strconv.Unquote(string(jsonField))
// It's possible that what we were given is an integer or float
// in which case, calling Unquote isn't doing us a lot of good.
// Therefore, we just convert to a string value and proceed.
if err != nil {
return string(jsonField)
unquotedField = string(jsonField)
}

return formatField(unquotedField, field.Kind)
Expand All @@ -89,19 +93,33 @@ func parseField(parsedLine any, field config.Field) string {
return "-"
}

//nolint:cyclop // The cyclomatic complexity here is so high because of the number of FieldKinds.
func formatField(
value string,
kind config.FieldKind,
) string {
value = strings.TrimSpace(value)

// Numeric time attempts to infer the duration based on the length of the string
if kind == config.FieldKindNumericTime {
kind = guessTimeFieldKind(value)
}

switch kind {
case config.FieldKindMessage:
return formatMessage(value)
case config.FieldKindLevel:
return string(ParseLevel(formatMessage(value)))
case config.FieldKindTime:
return formatMessage(value)
case config.FieldKindNumericTime:
return formatMessage(value)
case config.FieldKindSecondTime:
return formatMessage(formatTimeString(value, "s"))
case config.FieldKindMilliTime:
return formatMessage(formatTimeString(value, "ms"))
case config.FieldKindMicroTime:
return formatMessage(formatTimeString(value, "us"))
case config.FieldKindAny:
return formatMessage(value)
default:
Expand Down Expand Up @@ -166,3 +184,38 @@ func formatMessage(msg string) string {

return msg
}

// We can only guess the time via a heuristic. We do this by looking at the number of digits
// (before the decimal point) in the string. This is far from perfect.
func guessTimeFieldKind(timeStr string) config.FieldKind {
intValue, err := strconv.ParseInt(strings.Split(timeStr, ".")[0], 10, 64)
if err != nil {
return config.FieldKindTime
}

if intValue <= 0 {
return config.FieldKindTime
}

intLength := len(strconv.FormatInt(intValue, 10))

switch {
case intLength <= 10:
return config.FieldKindSecondTime
case intLength > 10 && intLength <= 13:
return config.FieldKindMilliTime
case intLength > 13 && intLength <= 16:
return config.FieldKindMicroTime
default:
return config.FieldKindTime
}
}

func formatTimeString(timeStr string, unit string) string {
duration, err := time.ParseDuration(timeStr + unit)
if err != nil {
return timeStr
}

return time.UnixMilli(0).Add(duration).UTC().Format(time.RFC3339)
}

0 comments on commit e3aacf1

Please sign in to comment.