diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c2246b..ce33d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +- Add [`@timestamp` diff highlighting](README.md#timestamp-diff-highlighting): + the part of the timestamp that has changed from the preceding record is + underlined (in the default color scheme). This highlighting can be turned + off with the `timestampShowDiff: false` config var. + ([#20](https://github.com/trentm/go-ecslog/pull/20)) + - Add `ecsLenient: false` config option to allow rendering of lines that are likely ECS-compatible, but do not have all three required ecs-logging fields: `@timestamp`, `ecs.version`, `log.level`. Only one of those three is required diff --git a/README.md b/README.md index 0daac64..2861daa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # ecslog -A CLI for pretty-printing (and filtering) of [ecs-logging](https://www.elastic.co/guide/en/ecs-logging/overview/master/intro.html) -formatted log files. +`ecslog` is a CLI for pretty-printing (and filtering) of log files in +[ecs-logging](https://www.elastic.co/guide/en/ecs-logging/overview/master/intro.html) +format. # Install @@ -17,9 +18,23 @@ Or you can build from source via: git clone git@github.com:trentm/go-ecslog.git cd go-ecslog - make + make # produces "./ecslog", a single binary you can put on your PATH ./ecslog --version +# Features + +TODO: fill this out + +## `@timestamp` diff highlighting + +By default, `ecslog` will highlight the change in a log record's `@timestamp` +from the previous log record. With the "default" formatter, the changed part +of the timestamp is underlined. For example: + +![screenshot of @timestamp diff highlighting](./docs/img/timestamp-diff-highlighting.png) + +This can be turned off with the `timestampShowDiff=false` config var. + # Goals @@ -99,11 +114,11 @@ elide some fields, typically for compactness. # Config file -Any of the following `ecslog` options can be set in a "~/.ecslog.toml" file. +Any of the following `ecslog` options can be set in a `~/.ecslog.toml` file. See https://toml.io/ for TOML syntax information. The `--no-config` option can -be used to ignore "~/.ecslog.toml", if there is one. +be used to ignore `~/.ecslog.toml`, if there is one. -For example: +An example config: ``` format="compact" @@ -117,7 +132,7 @@ Set the output format name (a string, equivalent of `-f, --format` option). Valid values are: "default" (the default), "compact", "ecs", "simple" ``` -format="compact" +format="default" ``` ### config: color @@ -138,7 +153,7 @@ records. Valid values are: -1 (to use the default 16384), or a value between 1 and 1048576 (inclusive). ``` -maxLineLen=32768 +maxLineLen=16384 ``` ### config: ecsLenient @@ -154,7 +169,17 @@ those three fields. Set `ecsLenient` to true to tell `ecslog` to attempt to rendering any log record that has **at least one** of these fields. ``` -ecsLenient=true +ecsLenient=false +``` + +### config: timestampShowDiff + +If coloring the output (see [config: color](#config-color) above), by default +`ecslog` will style the change in the timestamp from the preceding log record. +Set this config var to `false` to turn off this styling. + +``` +timestampShowDiff=true ``` diff --git a/TODO.md b/TODO.md index 208f1f9..cc8ea17 100644 --- a/TODO.md +++ b/TODO.md @@ -7,8 +7,6 @@ - README needs a once-over - review TODOs in the code - clear out all panic()s and probably lo?g.Fatal()s? Perhaps remove from 'lg' pkg -- releases: because unsigned dev thing on Mac, it would be nice to get into - brew. Look at goreleaser again # mvp @@ -36,14 +34,11 @@ # docs - specify that multifile behaviour may change later to merge on @timestamp + (https://github.com/trentm/go-ecslog/issues/16) # later - learn about verifiable builds: https://goreleaser.com/customization/gomod/ -- Is there a way to do releases for macOS and not have users hit the - "Developer cannot be verified" error? - https://stackoverflow.com/questions/59890359/developer-cannot-be-verified-macos-error-exception-a-move-to-trash-b-cancel - Tarball? Zip? Installer? Verifying with mac somehow (ew)? Brew tap? - Clickable file+linenum. If the ECS log record includes file name and line fields (https://www.elastic.co/guide/en/ecs/current/ecs-log.html#field-log-origin-file-line) the it would be nice if the default rendering of that (perhaps in the title line?) @@ -54,6 +49,9 @@ line number. What about ecslog (or another project) providing a small command to do that mapping (of log.origin.file.{name,line}) to local paths and/or GH links? Then provide docs on setting that up. +- consider https://github.com/muesli/termenv for ANSI color handling. + Supports truecolor and degradation. Supports templates which might be + useful for config-based custom styling. - "http" output format -> fieldRenderers? - coloring for added zap and other levels (test case for this) - get ECS log examples from all the ecs-logging-$lang examples to learn from @@ -77,6 +75,7 @@ - benchmarking to be able to test out "TODO perf" ideas - godoc and examples (https://blog.golang.org/examples) + # musing on custom formats/profiles ~/.ecslog.toml @@ -109,7 +108,7 @@ a format now. *Or* could still be "format", but the default built-in formats ecslog -f trent -If doing this (a format include the other attributes), then need to *not* +If doing this (a format includes the other attributes), then need to *not* allow top-level attributes in config, i.e. NOT this # NOT this diff --git a/cmd/ecslog/main.go b/cmd/ecslog/main.go index d13fc65..45a4ed7 100644 --- a/cmd/ecslog/main.go +++ b/cmd/ecslog/main.go @@ -150,6 +150,11 @@ func main() { ecsLenient = cfgECSLenient } + timestampShowDiff := true + if cfgTimestampShowDiff, ok := cfg.GetBool("timestampShowDiff"); ok { + timestampShowDiff = cfgTimestampShowDiff + } + r, err := ecslog.NewRenderer( shouldColorize, *flagColorScheme, @@ -157,6 +162,7 @@ func main() { maxLineLen, excludeFields, ecsLenient, + timestampShowDiff, ) if err != nil { printError(err.Error()) diff --git a/docs/img/timestamp-diff-highlighting.png b/docs/img/timestamp-diff-highlighting.png new file mode 100644 index 0000000..b3ea2af Binary files /dev/null and b/docs/img/timestamp-diff-highlighting.png differ diff --git a/examples/timestamp-diff-highlighting.log b/examples/timestamp-diff-highlighting.log new file mode 100644 index 0000000..e54ab7d --- /dev/null +++ b/examples/timestamp-diff-highlighting.log @@ -0,0 +1,7 @@ +{"log.level":"debug","@timestamp":"2021-04-15T04:21:16.049Z","ecs":{"version":"1.5.0"},"message":"server started"} +{"log.level":"debug","@timestamp":"2021-04-15T04:22:30.507Z","ecs":{"version":"1.5.0"},"message":"minutes later"} +{"log.level":"debug","@timestamp":"2021-04-15T04:22:32.742Z","ecs":{"version":"1.5.0"},"message":"a few seconds later"} +{"log.level":"debug","@timestamp":"2021-04-15T04:22:33.170Z","ecs":{"version":"1.5.0"},"message":"in the next second"} +{"log.level":"debug","@timestamp":"2021-04-15T04:22:33.493Z","ecs":{"version":"1.5.0"},"message":"in the same second"} +{"log.level":"debug","@timestamp":"2021-04-15T04:22:33.497Z","ecs":{"version":"1.5.0"},"message":"just a few ms later"} +{"log.level":"debug","@timestamp":"2021-04-16T03:21:40.615Z","ecs":{"version":"1.5.0"},"message":"almost a day later"} diff --git a/internal/ansipainter/ansipainter.go b/internal/ansipainter/ansipainter.go index 260dbc0..28cc476 100644 --- a/internal/ansipainter/ansipainter.go +++ b/internal/ansipainter/ansipainter.go @@ -100,7 +100,8 @@ type ANSIPainter struct { painting bool } -// Paint ... TODO:doc +// Paint with write the ANSI code to start styling with the ANSI SGR configured +// for the given `role`. func (p *ANSIPainter) Paint(b *strings.Builder, role string) { sgr, ok := p.sgrFromRole[role] if ok { @@ -111,11 +112,18 @@ func (p *ANSIPainter) Paint(b *strings.Builder, role string) { } } -// Reset ... TODO:doc +// PaintAttrs will write the ANSI code to start styling with the given +// attributes. +func (p *ANSIPainter) PaintAttrs(b *strings.Builder, attrs []Attribute) { + b.WriteString(sgrFromAttrs(attrs)) + p.painting = true +} + +// Reset will write the ANSI SGR to reset styling, if necessary. func (p *ANSIPainter) Reset(b *strings.Builder) { - // TODO: skip this if there wasn't a code given to previous Paint -> p.painting if p.painting { b.WriteString(sgrReset) + p.painting = false } } @@ -125,19 +133,26 @@ func New(attrsFromRole map[string][]Attribute) *ANSIPainter { p := ANSIPainter{} p.sgrFromRole = make(map[string]string) for role, attrs := range attrsFromRole { - sgr := escape + "[" - for i, attr := range attrs { - if i > 0 { - sgr += ";" - } - sgr += strconv.Itoa(int(attr)) + if len(attrs) > 0 { + p.sgrFromRole[role] = sgrFromAttrs(attrs) } - sgr += "m" - p.sgrFromRole[role] = sgr } return &p } +// sgrFromAttrs returns the ANSI escape code (SGR) for an array of attributes. +func sgrFromAttrs(attrs []Attribute) string { + sgr := escape + "[" + for i, attr := range attrs { + if i > 0 { + sgr += ";" + } + sgr += strconv.Itoa(int(attr)) + } + sgr += "m" + return sgr +} + // NoColorPainter is a painter that emits no ANSI codes. var NoColorPainter = New(nil) @@ -156,7 +171,7 @@ var BunyanPainter = New(map[string][]Attribute{ var PinoPrettyPainter = New(map[string][]Attribute{ "message": {FgCyan}, "trace": {FgHiBlack}, // FgHiBlack is chalk's conversion of "grey". - "debug": {FgBlue}, // TODO: is this blue visible on cmd.exe? + "debug": {FgBlue}, // TODO: is this blue visible on cmd.exe defaults? "info": {FgGreen}, "warn": {FgYellow}, "error": {FgRed}, @@ -164,9 +179,15 @@ var PinoPrettyPainter = New(map[string][]Attribute{ }) // DefaultPainter implements the stock default color scheme for `ecslog`. -// TODO: test on windows -// TODO: could add styles for punctuation (jq bolds them, I'd tend to make them faint) +// +// On timestamp styling: I wanted this to be somewhat subtle but not too subtle +// to be able to read. I like timestampDiff=Underline or timestampSame=Faint, +// but not both together. Anything else was too subtle (Italic) or too +// distracting (fg or bg colors). Perhaps with True Color this could be better. var DefaultPainter = New(map[string][]Attribute{ + "timestamp": {}, + "timestampSame": {}, + "timestampDiff": {Underline}, "message": {FgCyan}, "extraField": {Bold}, "jsonObjectKey": {FgHiBlue}, diff --git a/internal/ecslog/ecslog.go b/internal/ecslog/ecslog.go index 8c1fb29..6b046c3 100644 --- a/internal/ecslog/ecslog.go +++ b/internal/ecslog/ecslog.go @@ -25,19 +25,22 @@ const defaultMaxLineLen = 16384 // Renderer is the class used to drive ECS log rendering (aka pretty printing). type Renderer struct { - parser fastjson.Parser - painter *ansipainter.ANSIPainter - formatName string - formatter Formatter - maxLineLen int - excludeFields []string - ecsLenient bool - levelFilter string - kqlFilter *kqlog.Filter - strict bool - - line []byte // the raw input line - logLevel string // cached "log.level", read during isECSLoggingRecord + parser fastjson.Parser + painter *ansipainter.ANSIPainter + formatName string + formatter Formatter + maxLineLen int + excludeFields []string + ecsLenient bool + timestampShowDiff bool + levelFilter string + kqlFilter *kqlog.Filter + strict bool + + line []byte // the raw input line + logLevel string // cached "log.level", read during isECSLoggingRecord + lastTimestampBuf []byte // buffer to hold lastTimestamp values + lastTimestamp []byte // last @timestamp (a slice of lastTimestampBuf) } // NewRenderer returns a new ECS logging log renderer. @@ -54,7 +57,9 @@ type Renderer struct { // to be an ecs-logging record it must have ecs.version, log.level, and // @timestamp. If this option is true, it will only require *one* of those // fields to exist. -func NewRenderer(shouldColorize, colorScheme, formatName string, maxLineLen int, excludeFields []string, ecsLenient bool) (*Renderer, error) { +// - `timestampShowDiff` is a bool indicating if the @timestamp diff from the +// preceding log record should be styled. +func NewRenderer(shouldColorize, colorScheme, formatName string, maxLineLen int, excludeFields []string, ecsLenient, timestampShowDiff bool) (*Renderer, error) { // Get appropriate "painter" for terminal coloring. var painter *ansipainter.ANSIPainter if shouldColorize == "auto" { @@ -79,6 +84,8 @@ func NewRenderer(shouldColorize, colorScheme, formatName string, maxLineLen int, } case "no": painter = ansipainter.NoColorPainter + // No point in calculating timestamp diff if not styling. + timestampShowDiff = false default: return nil, fmt.Errorf("invalid value for shouldColorize: %s", shouldColorize) } @@ -104,12 +111,17 @@ func NewRenderer(shouldColorize, colorScheme, formatName string, maxLineLen int, lg.Printf("create renderer: formatName=%q, shouldColorize=%q, colorScheme=%q, maxLineLen=%d\n", formatName, shouldColorize, colorScheme, maxLineLen) return &Renderer{ - painter: painter, - formatName: formatName, - formatter: formatter, - maxLineLen: maxLineLen, - excludeFields: excludeFields, - ecsLenient: ecsLenient, + painter: painter, + formatName: formatName, + formatter: formatter, + maxLineLen: maxLineLen, + excludeFields: excludeFields, + ecsLenient: ecsLenient, + timestampShowDiff: timestampShowDiff, + + // Can a timestamp ever reasonably be longer than 64 chars? + // "2021-04-15T04:22:29.507Z" is 24. + lastTimestampBuf: make([]byte, 64), }, nil } diff --git a/internal/ecslog/ecslog_test.go b/internal/ecslog/ecslog_test.go index 64fc833..dad6930 100644 --- a/internal/ecslog/ecslog_test.go +++ b/internal/ecslog/ecslog_test.go @@ -9,22 +9,26 @@ import ( ) type renderFileTestCase struct { - name string - shouldColorize string - colorScheme string - formatName string - ecsLenient bool - levelFilter string - kqlFilter string - input string - output string + name string + + // Renderer options + shouldColorize string + colorScheme string + formatName string + ecsLenient bool + levelFilter string + kqlFilter string + timestampShowDiff bool + + input string + output string } var renderFileTestCases = []renderFileTestCase{ // Non-ecs-logging lines { "empty object", - "no", "", "default", false, "", "", + "no", "", "default", false, "", "", false, "{}", "{}\n", }, @@ -32,19 +36,19 @@ var renderFileTestCases = []renderFileTestCase{ // Basics { "basic", - "no", "", "default", false, "", "", + "no", "", "default", false, "", "", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"}`, "[2021-01-19T22:51:12.142Z] INFO: hi\n", }, { "basic, extra var", - "no", "", "default", false, "", "", + "no", "", "default", false, "", "", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi","foo":"bar"}`, "[2021-01-19T22:51:12.142Z] INFO: hi\n foo: \"bar\"\n", }, { "no message is allowed", - "no", "", "default", false, "", "", + "no", "", "default", false, "", "", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"foo":"bar"}`, "[2021-01-19T22:51:12.142Z] INFO:\n foo: \"bar\"\n", }, @@ -52,33 +56,41 @@ var renderFileTestCases = []renderFileTestCase{ // Coloring { "coloring 1", - "yes", "default", "default", false, "", "", + "yes", "default", "default", false, "", "", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"}`, "[2021-01-19T22:51:12.142Z] \x1b[32m INFO\x1b[0m: \x1b[36mhi\x1b[0m\n", }, + { + "timestamp diff highlighting 1", + "yes", "default", "default", false, "", "", true, + `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"} +{"log.level":"info","@timestamp":"2021-01-19T22:51:23.456Z","ecs":{"version":"1.5.0"},"message":"hi"}`, + "[2021-01-19T22:51:12.142Z] \x1b[32m INFO\x1b[0m: \x1b[36mhi\x1b[0m\n" + + "[2021-01-19T22:51:\x1b[4m23.456\x1b[0mZ] \x1b[32m INFO\x1b[0m: \x1b[36mhi\x1b[0m\n", + }, // KQL filtering { "kql filtering, yep", - "no", "", "default", false, "", "foo:bar", + "no", "", "default", false, "", "foo:bar", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi","foo":"bar"}`, "[2021-01-19T22:51:12.142Z] INFO: hi\n foo: \"bar\"\n", }, { "kql filtering, nope", - "no", "", "default", false, "", "foo:baz", + "no", "", "default", false, "", "foo:baz", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi","foo":"bar"}`, "", }, { "kql filtering, log.level range query, yep", - "no", "", "default", false, "", "log.level > debug", + "no", "", "default", false, "", "log.level > debug", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"}`, "[2021-01-19T22:51:12.142Z] INFO: hi\n", }, { "kql filtering, log.level range query, nope", - "no", "", "default", false, "", "log.level > warn", + "no", "", "default", false, "", "log.level > warn", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"}`, "", }, @@ -86,25 +98,25 @@ var renderFileTestCases = []renderFileTestCase{ // ecsLenient { "lenient: missing log.level", - "no", "", "default", true, "", "", + "no", "", "default", true, "", "", false, `{"@timestamp":"2021-01-19T22:51:12.142Z","ecs":{"version":"1.5.0"},"message":"hi"}`, "[2021-01-19T22:51:12.142Z] : hi\n", }, { "lenient: missing @timestamp", - "no", "", "default", true, "", "", + "no", "", "default", true, "", "", false, `{"log.level":"info","ecs":{"version":"1.5.0"},"message":"hi"}`, " INFO: hi\n", }, { "lenient: missing ecs.version", - "no", "", "default", true, "", "", + "no", "", "default", true, "", "", false, `{"log.level":"info","@timestamp":"2021-01-19T22:51:12.142Z","message":"hi"}`, "[2021-01-19T22:51:12.142Z] INFO: hi\n", }, { "lenient: @timestamp only", - "no", "", "default", true, "", "", + "no", "", "default", true, "", "", false, `{"@timestamp":"2021-01-19T22:51:12.142Z","message":"hi"}`, "[2021-01-19T22:51:12.142Z] : hi\n", }, @@ -120,6 +132,7 @@ func TestRenderFile(t *testing.T) { -1, []string{}, tc.ecsLenient, + tc.timestampShowDiff, ) if err != nil { t.Errorf("ecslog.NewRenderer(%q, %q, %q) error: %s", diff --git a/internal/ecslog/formatters.go b/internal/ecslog/formatters.go index fa23613..c9be4d3 100644 --- a/internal/ecslog/formatters.go +++ b/internal/ecslog/formatters.go @@ -79,6 +79,156 @@ func (f *compactFormatter) formatRecord(r *Renderer, rec *fastjson.Value, b *str }) } +// commonPrefixIdx returns the largest index into a and b for which the bytes +// are the same -- `a[:idx] == b[:idx]`. idx will not exceed the length of the +// shortest slice. +func commonPrefixIdx(a, b []byte) int { + shorter := len(a) + if len(b) < shorter { + shorter = len(b) + } + + idx := 0 + for ; idx < shorter; idx++ { + if a[idx] != b[idx] { + break + } + } + return idx +} + +// commonRFC3339TzIdx returns the index into *b* marking the start of the +// RFC 3339 (https://datatracker.ietf.org/doc/html/rfc3339) timezone (called +// "time-offset" in the spec) if: +// 1. there is one, and +// 2. if that time-offset is common to a and b. +// Otherwise it returns -1. +// +// This is not doing a full RFC 3339 parse, so it is best effort. +// +// Examples: +// a=2021-05-20T22:50:44+07:00 +// b=2021-05-22T00:12:34.567+07:00 +// -> idx=23 (pointing to the '+') +// +// a=2021-05-20T22:50:44Z +// b=2021-05-22T00:12:34Z +// -> idx=19 (pointing to the 'Z') +func commonRFC3339TzIdx(a, b []byte) int { + if len(a) == 0 || len(b) == 0 { + return -1 + } + + if a[len(a)-1] == 'Z' { + if b[len(b)-1] == 'Z' { + return len(b) - 1 + } + return -1 + } + + if len(a) < 6 || len(b) < 6 { + return -1 + } + + if bytes.Equal(a[len(a)-6:], b[len(b)-6:]) { + return len(b) - 6 + } + + return -1 +} + +// formatTimestamp will write a styled `@timestamp` field to `b`. +// For example: +// `[2021-04-15T04:22:29.507Z] ` +// +// If the `timestampShowDiff` config is true (the default), then given: +// lastTimestamp = '2021-05-20T22:50:44+00:00' +// @timestamp = '2021-05-20T22:51:23+00:00' +// we expect: +// `[2021-05-20T22:51:23+00:00] ` +// ^ ^-- styled with role "timestamp" +// ^^^^^^^^^^^^^^^ ^^^^^^--- styled with role "timestampSame" +// ^^^^--------- styled with role "timestampDiff" +// +// The `[` and `]` delimiters are styled with role "timestamp", unless the +// whole timestamp is the same or different -- in which case the "timestampSame" +// or "timestampDiff" role is used, respectively. +func formatTimestamp(r *Renderer, rec *fastjson.Value, b *strings.Builder) { + timestamp := jsonutils.ExtractValue(rec, "@timestamp").GetStringBytes() + if r.timestampShowDiff { + // If we are styling timestamp diffs, finish by making a copy + // of this timestamp for rendering the next record. + defer func() { + n := copy(r.lastTimestampBuf, timestamp) + r.lastTimestamp = r.lastTimestampBuf[:n] + }() + } + + if timestamp == nil { + return + } + + if !r.timestampShowDiff || r.lastTimestamp == nil { + // Not showing timestamp diffs, or this is the first timestamp. + r.painter.Paint(b, "timestamp") + b.WriteByte('[') + b.Write(timestamp) + b.WriteByte(']') + r.painter.Reset(b) + b.WriteByte(' ') + return + } + + preIdx := commonPrefixIdx(r.lastTimestamp, timestamp) + if preIdx == len(timestamp) { + // Timestamps are the same. + r.painter.Paint(b, "timestampSame") + b.WriteByte('[') + b.Write(timestamp) + b.WriteByte(']') + r.painter.Reset(b) + b.WriteByte(' ') + return + } + + sufIdx := commonRFC3339TzIdx(r.lastTimestamp, timestamp) + if preIdx == 0 && sufIdx == -1 { + // Timestamps are completely different. + r.painter.Paint(b, "timestampDiff") + b.WriteByte('[') + b.Write(timestamp) + b.WriteByte(']') + r.painter.Reset(b) + b.WriteByte(' ') + return + } + + r.painter.Paint(b, "timestamp") + b.WriteByte('[') + r.painter.Reset(b) + if preIdx > 0 { + r.painter.Paint(b, "timestampSame") + b.Write(timestamp[:preIdx]) + r.painter.Reset(b) + } + if sufIdx == -1 { + r.painter.Paint(b, "timestampDiff") + b.Write(timestamp[preIdx:]) + r.painter.Reset(b) + } else { + r.painter.Paint(b, "timestampDiff") + b.Write(timestamp[preIdx:sufIdx]) + r.painter.Reset(b) + r.painter.Paint(b, "timestampSame") + b.Write(timestamp[sufIdx:]) + r.painter.Reset(b) + } + r.painter.Paint(b, "timestamp") + b.WriteByte(']') + r.painter.Reset(b) + b.WriteByte(' ') +} + func formatDefaultTitleLine(r *Renderer, rec *fastjson.Value, b *strings.Builder) { var val *fastjson.Value var logLogger []byte @@ -94,7 +244,6 @@ func formatDefaultTitleLine(r *Renderer, rec *fastjson.Value, b *strings.Builder hostHostname = val.GetStringBytes() } - timestamp := jsonutils.ExtractValue(rec, "@timestamp").GetStringBytes() message := jsonutils.ExtractValue(rec, "message").GetStringBytes() // Title line pattern: @@ -109,11 +258,7 @@ func formatDefaultTitleLine(r *Renderer, rec *fastjson.Value, b *strings.Builder // typical bunyan: [@timestamp] LEVEL (name/pid on host): message // typical pino: [@timestamp] LEVEL (pid on host): message // typical winston: [@timestamp] LEVEL: message - if timestamp != nil { - b.WriteByte('[') - b.Write(timestamp) - b.WriteString("] ") - } + formatTimestamp(r, rec, b) if r.logLevel != "" { r.painter.Paint(b, r.logLevel) fmt.Fprintf(b, "%5s", strings.ToUpper(r.logLevel))