v0.35.0 tui fixes
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-11 04:11:57 -04:00
1 parent 1abffcec6c
commit f4f8cc0986
24 files changed
+965 -295

No files matched your search

+72 -30
View File
@@ -1,13 +1,15 @@
// Package logline reads a log line of any shape (vif records, LogWisp's json,
// lw's own log, pino, zap, slog, docker, journald, ECS, GCP, plain text) into
// one set of roles, and names the runes a terminal would act on. It links no
// terminal code, so a viewer, a sink and the configuration page can share it.
// lw's own log, pino, zap, slog, logfmt, docker, journald, ECS, GCP, plain
// text) into one set of roles, and names the runes a terminal would act on. It
// links no terminal code, so a viewer, a sink and the configuration page can
// share it.
package logline
import (
"bytes"
"encoding/hex"
"math"
"slices"
"strconv"
"strings"
"time"
@@ -70,8 +72,8 @@ type member struct {
}
// Read finds e's roles in its message, a JSON record (also one inside such a
// record's message) or text with a leading RFC3339 stamp, and in its fields;
// a role the line does not name is the entry's.
// record's message) or text, and in its fields; a role the line does not name
// is the entry's.
func Read(e core.LogEntry) Line {
l, at, ok := record(strings.TrimSpace(e.Message), "")
if ok {
@@ -79,12 +81,7 @@ func Read(e core.LogEntry) Line {
l = merge(l, in)
}
} else {
l = Line{Message: e.Message, Layout: "text"}
if stamp, rest, ok := strings.Cut(e.Message, " "); ok {
if at, ok := readStamp(stamp); ok {
l.Time, l.Stamp, l.Message, l.Layout = at, stamp, strings.TrimLeft(rest, " "), "text, stamped"
}
}
l = text(e.Message)
}
if f := strings.TrimSpace(string(e.Fields)); f != "" {
_, kind, _ := scanValue(f, 0)
@@ -135,19 +132,7 @@ func record(s, in string) (l Line, at string, ok bool) {
}
}
if m := find(ms, roleTime); m != nil {
l.Stamp = unquote(m.val, m.kind)
l.Time, _ = readTime(l.Stamp)
}
if m := find(ms, roleLevel); m != nil {
l.Level = level(m.key, unquote(m.val, m.kind))
}
if m := find(ms, roleTag); m != nil {
l.Tag = unquote(m.val, m.kind)
}
if m := find(ms, roleMsg); m != nil {
l.Message, at = unquote(m.val, m.kind), m.key
} else if msg >= 0 {
if at = l.take(ms); at == "" && msg >= 0 {
ms[msg].used = true
l.Message, at = unquote(ms[msg].val, kStr), envAt
}
@@ -160,14 +145,71 @@ func record(s, in string) (l Line, at string, ok bool) {
if !whole {
l.Layout += ", cut short"
}
if at != "" {
l.Layout += ", message in " + at
} else {
l.Layout += ", no message"
}
l.Layout += messageIn(at)
return l, at, true
}
// text reads a line that is no JSON record: a leading RFC3339 stamp, then a
// word naming a level; the rest is the message, or a record's roles when it
// is logfmt pairs, a level or a message among them (slog's text handler)
func text(s string) Line {
l, layout := Line{Message: s}, ""
if stamp, rest, ok := strings.Cut(s, " "); ok {
if at, ok := readStamp(stamp); ok {
l.Time, l.Stamp, l.Message, layout = at, stamp, strings.TrimLeft(rest, " "), ", stamped"
word, rest, _ := strings.Cut(l.Message, " ")
if lv, i, j := core.LevelWord(word, ""); lv != "" && i == 0 && j == len(word) {
l.Level, l.Message, layout = lv, strings.TrimLeft(rest, " "), layout+", level word"
}
}
}
var ms []member
named := false
pairs := eachPair(l.Message, func(k, v string) {
ms = append(ms, member{key: k, val: v, kind: kText})
named = named || slices.Contains(roles[roleLevel], k) || slices.Contains(roles[roleMsg], k)
})
if !pairs || !named {
l.Layout = "text" + layout
return l
}
l.Message = ""
at := l.take(ms)
l.Fields = appendFields(nil, ms)
l.Layout = "logfmt" + layout + messageIn(at)
return l
}
// take sets the roles ms names and returns the message's key, "" for none; a
// stamp or level read off the line's start stays, the member a field
func (l *Line) take(ms []member) (at string) {
if l.Stamp == "" {
if m := find(ms, roleTime); m != nil {
l.Stamp = unquote(m.val, m.kind)
l.Time, _ = readTime(l.Stamp)
}
}
if l.Level == "" {
if m := find(ms, roleLevel); m != nil {
l.Level = level(m.key, unquote(m.val, m.kind))
}
}
if m := find(ms, roleTag); m != nil {
l.Tag = unquote(m.val, m.kind)
}
if m := find(ms, roleMsg); m != nil {
l.Message, at = unquote(m.val, m.kind), m.key
}
return at
}
func messageIn(at string) string {
if at == "" {
return ", no message"
}
return ", message in " + at
}
// envelope appends the leaves of envelope name's value v; msg indexes its
// message in ms and at says where it was, -1 and "" when it holds none
func envelope(ms []member, name, v string, kind byte) (_ []member, msg int, at string) {
@@ -248,7 +290,7 @@ func find(ms []member, r int) *member {
for _, key := range roles[r] {
for i := range ms {
m := &ms[i]
if m.key == key && !m.env && !m.used && (m.kind == kStr || m.kind == kNum) {
if m.key == key && !m.env && !m.used && (m.kind == kStr || m.kind == kNum || m.kind == kText) {
m.used = true
return m
}
+33 -4
View File
@@ -20,7 +20,8 @@ func arrived(line string) core.LogEntry {
// Each writer's line yields its time, level, tag and message from where that
// writer puts them, the rest as fields in its order; the want is "layout |
// stamp=time | level | tag | message | fields". vif's, lw's and slog's lines
// are their writers' output; the others follow their formats' documentation.
// are their writers' output (lw's key=value line as its log library writes one
// with FlagKV); the others follow their formats' documentation.
func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
for _, c := range []struct {
in core.LogEntry
@@ -65,6 +66,31 @@ func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
`json, message in msg | 2026-10-10T20:30:23.451515887Z=2026-10-10T20:30:23.451515887Z | INFO | app.log | "slog style" | user.id="7" user.name="a"`},
{arrived(`{"time":"2026-10-10T20:39:59.809016667Z","level":-4,"msg":"slog level as a number","attempt":2}`),
`json, message in msg | 2026-10-10T20:39:59.809016667Z=2026-10-10T20:39:59.809016667Z | DEBUG | app.log | "slog level as a number" | attempt="2"`},
// logfmt, slog's text handler and go-kit's: key=value pairs, a value
// quoted as Go quotes it
{arrived(`time=2026-10-11T05:23:21.410Z level=WARN msg="disk almost full" pct=91 disk.dev=/dev/sda1 err="open \"x\": denied"`),
`logfmt, message in msg | 2026-10-11T05:23:21.410Z=2026-10-11T05:23:21.41Z | WARN | app.log | "disk almost full" | pct="91" disk.dev="/dev/sda1" err="open \"x\": denied"`},
{arrived(`level=info ts=2026-10-10T12:00:00.000Z caller=main.go:12 listen=:8080`),
`logfmt, no message | 2026-10-10T12:00:00.000Z=2026-10-10T12:00:00Z | INFO | app.log | "" | caller="main.go:12" listen=":8080"`},
// lw's own log as text: the level word after the stamp is the level, taken
// off the message (the entry names none here); its pairs, once key=value,
// are a record's
{core.LogEntry{Source: "lw", Message: `2026-10-11T05:23:20.28585127Z INFO msg "Waiting for directory" component file_source instance_id file directory /home/me/.local/state/vif/log`},
`text, stamped, level word | 2026-10-11T05:23:20.28585127Z=2026-10-11T05:23:20.28585127Z | INFO | lw | "msg \"Waiting for directory\" component file_source instance_id file directory /home/me/.local/state/vif/log" | `},
{arrived(`2026-10-11T05:19:17.289770496Z INFO msg="Waiting for directory" component=file_source instance_id=file directory=/home/me/.local/state/vif/log`),
`logfmt, stamped, level word, message in msg | 2026-10-11T05:19:17.289770496Z=2026-10-11T05:19:17.289770496Z | INFO | file_source | "Waiting for directory" | instance_id="file" directory="/home/me/.local/state/vif/log"`},
// the line's start keeps its stamp and level word; pairs naming them are fields
{arrived(`2026-10-11T05:00:00Z WARN msg="request done" t=3 level=debug`),
`logfmt, stamped, level word, message in msg | 2026-10-11T05:00:00Z=2026-10-11T05:00:00Z | WARN | app.log | "request done" | t="3" level="debug"`},
// an '=' in text is text: pairs alone, a level or message among them, count
{arrived(`x=1 y=2 note="no level or message key"`),
`text | =- | | app.log | "x=1 y=2 note=\"no level or message key\"" | `},
{arrived(`msg=not only pairs`),
`text | =- | | app.log | "msg=not only pairs" | `},
{arrived(`level=INFO msg="clipped at an escape\`),
`text | =- | INFO | app.log | "level=INFO msg=\"clipped at an escape\\" | `},
{arrived(`2026-10-10T12:00:00Z DEBUG cache hit ratio=0.93`),
`text, stamped, level word | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | DEBUG | app.log | "cache hit ratio=0.93" | `},
// docker's log ends in its newline; the level is the entry's
{arrived(`{"log":"docker line ERROR x\n","stream":"stderr","time":"2026-10-10T12:00:00.1Z"}`),
`json, message in log | 2026-10-10T12:00:00.1Z=2026-10-10T12:00:00.1Z | ERROR | app.log | "docker line ERROR x" | stream="stderr"`},
@@ -86,9 +112,12 @@ func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
{arrived(`{"msg":"deep","a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":{"i":{"j":1}}}}}}}}}}`),
`json, message in msg | =- | | app.log | "deep" | a.b.c.d.e.f.g.h.i="{\"j\":1}"`},
{arrived(`2026-10-10T12:00:00+02:00 ERROR db: connection refused`),
`text, stamped | 2026-10-10T12:00:00+02:00=2026-10-10T10:00:00Z | ERROR | app.log | "ERROR db: connection refused" | `},
{arrived(`2026-10-10T12:00:00.5 INFO a stamp without a zone is UTC`),
`text, stamped | 2026-10-10T12:00:00.5=2026-10-10T12:00:00.5Z | INFO | app.log | "INFO a stamp without a zone is UTC" | `},
`text, stamped, level word | 2026-10-10T12:00:00+02:00=2026-10-10T10:00:00Z | ERROR | app.log | "db: connection refused" | `},
{arrived(`2026-10-10T12:00:00.5 a stamp without a zone is UTC`),
`text, stamped | 2026-10-10T12:00:00.5=2026-10-10T12:00:00.5Z | | app.log | "a stamp without a zone is UTC" | `},
// a level word is a whole word: one in brackets stays in the message
{arrived(`2026-10-10T12:00:00Z [WARN] disk almost full`),
`text, stamped | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "[WARN] disk almost full" | `},
// what only looks like a stamp is text: no day is invented
{arrived(`2026-13-45T99:99:99Z ERROR x`),
`text | =- | ERROR | app.log | "2026-13-45T99:99:99Z ERROR x" | `},
+42 -3
View File
@@ -1,8 +1,11 @@
package logline
import "encoding/json"
import (
"encoding/json"
"strconv"
)
// JSON token kinds
// JSON token kinds, and kText for a logfmt value, unquoted already
const (
kNone byte = 0
kStr byte = 's'
@@ -11,6 +14,7 @@ const (
kNull byte = 'z'
kObj byte = 'o'
kArr byte = 'a'
kText byte = 't'
)
func skipSpace(s string, i int) int {
@@ -177,8 +181,43 @@ func eachItem(s string, i int, fn func(val string, kind byte)) bool {
return false
}
// eachPair calls fn for each pair of a logfmt line, key=value with the value
// bare or quoted as Go quotes it; it reports whether s is such pairs alone.
func eachPair(s string, fn func(key, val string)) bool {
bare := func(i int) int {
for i < len(s) && s[i] > ' ' && s[i] != '=' && s[i] != '"' {
i++
}
return i
}
for i := skipSpace(s, 0); i < len(s); i = skipSpace(s, i) {
k := i
if i = bare(k); i == k || i == len(s) || s[i] != '=' {
return false
}
key, v := s[k:i], i+1
i = bare(v)
val := s[v:i]
if i == v && i < len(s) && s[i] == '"' {
var ok bool
var err error
if i, ok = scanString(s, v); !ok {
return false
}
if val, err = strconv.Unquote(s[v:i]); err != nil {
return false
}
}
if i < len(s) && s[i] > ' ' {
return false
}
fn(key, val)
}
return true
}
// unquote returns the content of a string token, decoding only when it holds
// an escape; any other token is returned as its JSON.
// an escape; any other token, a logfmt value too, is returned as it is.
func unquote(tok string, kind byte) string {
if kind != kStr || len(tok) < 2 {
return tok