Skip to content

Commit e3aacf1

Browse files
cosmastechhedhyw
andauthored
feat: support human readable timestamps (#38)
Co-authored-by: Maksym Kryvchun <[email protected]>
1 parent c8ce24a commit e3aacf1

File tree

6 files changed

+336
-16
lines changed

6 files changed

+336
-16
lines changed

README.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,14 @@ Example configuration:
107107
"title": "Time", // Max length is 32.
108108
// Kind affects rendering. There are:
109109
// * time;
110+
// * numerictime;
111+
// * secondtime;
112+
// * millitime;
113+
// * microtime;
110114
// * level;
111115
// * message;
112116
// * any.
113-
"kind": "time",
117+
"kind": "numerictime",
114118
"ref": [
115119
// The application will display the first matched value.
116120
"$.timestamp",
@@ -153,6 +157,28 @@ Example configuration:
153157
}
154158
```
155159

160+
### Time Formats
161+
JSON Log Viewer can handle a variety of datetime formats when parsing your logs.
162+
163+
#### `time`
164+
This will return the exact value that was set in the JSON document.
165+
166+
#### `numerictime`
167+
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.
168+
169+
If a string such as `"2023-05-01T12:00:34Z"` or `"---"` is used, the value will just be carried forward to your column.
170+
171+
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.
172+
173+
#### `secondtime`
174+
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.
175+
176+
#### `millitime`
177+
Similar to `secondtime`, this will attempt to parse the value as number of milliseconds. Values accepted are integer, string, or float.
178+
179+
#### `microtime`
180+
Similar to `secondtime` and `millistime`, this will attempt to parse the value as number of microseconds. Values accepted are integer, string, or float.
181+
156182
## Resources
157183

158184
Alternatives:

assets/example.log

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,11 @@
4545
{"time":"1970-01-01T00:00:00.00","level":"DEBUG","message": "A house divided against itself cannot stand.","author": "Abraham Lincoln"}
4646
{"time":"1970-01-01T00:00:00.00","level":"TRACE","message": "Difficulties increase the nearer we get to the goal.","author": "Johann Wolfgang von Goethe"}
4747
plain text log
48+
{"time":"12444334.222","level":"TRACE","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
49+
{"time":12444334.222,"level":"INFO","message": "Laugh at the world's foolishness or weep over it, you will regret both","author": "Kierkegaard"}
50+
{"time":12444334,"level":"WARN","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
51+
{"time":"12444334","level":"DEBUG","message": "Wealth consists not in having great possessions, but in having few wants.","author": "Epictetus"}
52+
{"time":124999444443,"level":"TRACE","message": "Money doesn't talk, it swears","author": "Bob Dylan"}
53+
{"time":124999444443.45,"level":"WARN","message": "If a man knows not to which port he sails, no wind is favorable","author": "Seneca"}
54+
{"time":"124999444443.45","level":"VERBOSE","message": "Begin at once to live, and count each separate day as a separate life","author": "Seneca"}
55+
{"time":12499944444398.45,"level":"VERBOSE","message": "Begin at once to live, and count each separate day as a separate life","author": "Seneca"}

internal/pkg/config/config.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,20 @@ type FieldKind string
2626

2727
// Possible kinds.
2828
const (
29-
FieldKindTime FieldKind = "time"
30-
FieldKindMessage FieldKind = "message"
31-
FieldKindLevel FieldKind = "level"
32-
FieldKindAny FieldKind = "any"
29+
FieldKindTime FieldKind = "time"
30+
FieldKindNumericTime FieldKind = "numerictime"
31+
FieldKindSecondTime FieldKind = "secondtime"
32+
FieldKindMilliTime FieldKind = "millitime"
33+
FieldKindMicroTime FieldKind = "microtime"
34+
FieldKindMessage FieldKind = "message"
35+
FieldKindLevel FieldKind = "level"
36+
FieldKindAny FieldKind = "any"
3337
)
3438

3539
// Field customization.
3640
type Field struct {
3741
Title string `json:"title" validate:"required,min=1,max=32"`
38-
Kind FieldKind `json:"kind" validate:"required,oneof=time message level any"`
42+
Kind FieldKind `json:"kind" validate:"required,oneof=time message numerictime secondtime millitime microtime level any"`
3943
References []string `json:"ref" validate:"min=1,dive,required"`
4044
Width int `json:"width" validate:"min=0"`
4145
}
@@ -46,7 +50,7 @@ func GetDefaultConfig() *Config {
4650
Path: "default",
4751
Fields: []Field{{
4852
Title: "Time",
49-
Kind: FieldKindTime,
53+
Kind: FieldKindNumericTime,
5054
References: []string{"$.timestamp", "$.time", "$.t", "$.ts"},
5155
Width: 30,
5256
}, {

internal/pkg/config/config_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func ExampleGetDefaultConfig() {
103103
// "fields": [
104104
// {
105105
// "title": "Time",
106-
// "kind": "time",
106+
// "kind": "numerictime",
107107
// "ref": [
108108
// "$.timestamp",
109109
// "$.time",
@@ -208,6 +208,30 @@ func TestValidateField(t *testing.T) {
208208
value.Kind = config.FieldKindTime
209209
},
210210
IsValid: true,
211+
}, {
212+
Name: "kind_numeric_time",
213+
Apply: func(value *config.Field) {
214+
value.Kind = config.FieldKindNumericTime
215+
},
216+
IsValid: true,
217+
}, {
218+
Name: "kind_second_time",
219+
Apply: func(value *config.Field) {
220+
value.Kind = config.FieldKindSecondTime
221+
},
222+
IsValid: true,
223+
}, {
224+
Name: "kind_milli_time",
225+
Apply: func(value *config.Field) {
226+
value.Kind = config.FieldKindMilliTime
227+
},
228+
IsValid: true,
229+
}, {
230+
Name: "kind_micro_time",
231+
Apply: func(value *config.Field) {
232+
value.Kind = config.FieldKindMicroTime
233+
},
234+
IsValid: true,
211235
}, {
212236
Name: "unset_kind",
213237
Apply: func(value *config.Field) {

internal/pkg/source/entry.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"strconv"
88
"strings"
9+
"time"
910
"unicode"
1011

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

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

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

96+
//nolint:cyclop // The cyclomatic complexity here is so high because of the number of FieldKinds.
9297
func formatField(
9398
value string,
9499
kind config.FieldKind,
95100
) string {
96101
value = strings.TrimSpace(value)
97102

103+
// Numeric time attempts to infer the duration based on the length of the string
104+
if kind == config.FieldKindNumericTime {
105+
kind = guessTimeFieldKind(value)
106+
}
107+
98108
switch kind {
99109
case config.FieldKindMessage:
100110
return formatMessage(value)
101111
case config.FieldKindLevel:
102112
return string(ParseLevel(formatMessage(value)))
103113
case config.FieldKindTime:
104114
return formatMessage(value)
115+
case config.FieldKindNumericTime:
116+
return formatMessage(value)
117+
case config.FieldKindSecondTime:
118+
return formatMessage(formatTimeString(value, "s"))
119+
case config.FieldKindMilliTime:
120+
return formatMessage(formatTimeString(value, "ms"))
121+
case config.FieldKindMicroTime:
122+
return formatMessage(formatTimeString(value, "us"))
105123
case config.FieldKindAny:
106124
return formatMessage(value)
107125
default:
@@ -166,3 +184,38 @@ func formatMessage(msg string) string {
166184

167185
return msg
168186
}
187+
188+
// We can only guess the time via a heuristic. We do this by looking at the number of digits
189+
// (before the decimal point) in the string. This is far from perfect.
190+
func guessTimeFieldKind(timeStr string) config.FieldKind {
191+
intValue, err := strconv.ParseInt(strings.Split(timeStr, ".")[0], 10, 64)
192+
if err != nil {
193+
return config.FieldKindTime
194+
}
195+
196+
if intValue <= 0 {
197+
return config.FieldKindTime
198+
}
199+
200+
intLength := len(strconv.FormatInt(intValue, 10))
201+
202+
switch {
203+
case intLength <= 10:
204+
return config.FieldKindSecondTime
205+
case intLength > 10 && intLength <= 13:
206+
return config.FieldKindMilliTime
207+
case intLength > 13 && intLength <= 16:
208+
return config.FieldKindMicroTime
209+
default:
210+
return config.FieldKindTime
211+
}
212+
}
213+
214+
func formatTimeString(timeStr string, unit string) string {
215+
duration, err := time.ParseDuration(timeStr + unit)
216+
if err != nil {
217+
return timeStr
218+
}
219+
220+
return time.UnixMilli(0).Add(duration).UTC().Format(time.RFC3339)
221+
}

0 commit comments

Comments
 (0)