Files
vif-log/internal/logfile/record.go
T
2026-08-06 10:18:22 -04:00

242 lines
5.6 KiB
Go

package logfile
import (
"bytes"
"strconv"
)
// Column identifies a list column for focus, search scope and sorting.
type Column uint8
const (
ColAll Column = iota
ColTime
ColTick
ColSub
ColMsg
ColFields
ColCount
)
var columnName = [ColCount]string{"all", "time", "tick", "sub", "msg", "fields"}
func (c Column) String() string {
if c < ColCount {
return columnName[c]
}
return "?"
}
// Next returns the column d steps away, wrapping.
func (c Column) Next(d int) Column {
return Column(((int(c)+d)%int(ColCount) + int(ColCount)) % int(ColCount))
}
// Field is one member of the fields object; Val is the raw JSON token.
type Field struct {
Key string
Val []byte
Kind byte
}
// Record is the parsed form of a line. It is the only type that knows the log
// schema: a new sub, a renamed key or a second format touches this file alone.
type Record struct {
Meta Meta
Raw []byte
Time string
Level string
Sub string
Trace string
Fields []Field
Bad bool
msgIdx int // index into Fields of the discriminator, -1 if none
buf []byte // column rendering scratch, reused across calls
}
// msgKeys are the discriminator keys in precedence order. Most records use
// fields.msg; the logger self-report uses fields.type.
var msgKeys = [...]string{"msg", "type"}
// Parse fills r from line. Slices alias line and are reused across calls, so
// copy anything retained past the next Parse.
func (r *Record) Parse(m Meta, line []byte) {
r.Meta, r.Raw = m, line
r.Time, r.Level, r.Sub, r.Trace = "", "", "", ""
r.Fields = r.Fields[:0]
r.msgIdx = -1
r.Bad = m.Flags&FlagMalformed != 0 || len(line) == 0
if r.Bad {
return
}
eachField(line, skipSpace(line, 0), func(k, v []byte, kind byte) bool {
switch string(k) {
case "time":
r.Time = string(strTok(v))
case "level":
r.Level = string(strTok(v))
case "sub":
r.Sub = string(strTok(v))
case "trace":
r.Trace = unquote(v)
case "fields":
if kind == KObj {
eachField(v, 0, func(fk, fv []byte, fkind byte) bool {
r.Fields = append(r.Fields, Field{Key: string(fk), Val: fv, Kind: fkind})
return true
})
}
}
return true
})
// Resolve the discriminator once: input records carry both msg and type,
// and only the one actually used may be dropped from the fields text.
for _, k := range msgKeys {
for i := range r.Fields {
if r.Fields[i].Key == k && r.Fields[i].Kind == KStr {
r.msgIdx = i
break
}
}
if r.msgIdx >= 0 {
break
}
}
}
// Get returns the named field.
func (r *Record) Get(key string) (Field, bool) {
for _, f := range r.Fields {
if f.Key == key {
return f, true
}
}
return Field{}, false
}
// Msg returns the record's discriminator.
func (r *Record) Msg() string {
if r.msgIdx < 0 {
return ""
}
return unquote(r.Fields[r.msgIdx].Val)
}
// FollowValue returns the first string field other than the discriminator: the
// value distinguishing records that share a (sub, msg) pair — ev for event
// dispatch, service for service records. Empty when every field is numeric.
func (r *Record) FollowValue() string {
for i, f := range r.Fields {
if i == r.msgIdx || f.Kind != KStr {
continue
}
return unquote(f.Val)
}
return ""
}
const fieldsTextCap = 512
// FieldsText is the fields column exactly as the record list renders it.
func (r *Record) FieldsText() string {
r.buf = r.appendFields(r.buf[:0])
return string(r.buf)
}
// ColumnBytes renders one column's searchable text into a reused buffer, so
// search matches what is on screen rather than the raw JSON.
func (r *Record) ColumnBytes(c Column) []byte {
r.buf = r.buf[:0]
switch c {
case ColTime:
r.buf = append(r.buf, StampText(r.Meta)...)
case ColTick:
r.buf = strconv.AppendUint(r.buf, uint64(r.Meta.Tick), 10)
case ColSub:
r.buf = append(r.buf, r.Sub...)
case ColMsg:
r.buf = r.appendMsg(r.buf)
case ColFields:
r.buf = r.appendFields(r.buf)
default:
r.buf = append(r.buf, StampText(r.Meta)...)
r.buf = append(r.buf, ' ')
r.buf = append(r.buf, r.Sub...)
r.buf = append(r.buf, ' ')
r.buf = r.appendMsg(r.buf)
r.buf = append(r.buf, ' ')
r.buf = r.appendFields(r.buf)
}
return r.buf
}
func (r *Record) appendMsg(dst []byte) []byte {
if r.msgIdx < 0 {
return dst
}
return appendUnquoted(dst, r.Fields[r.msgIdx].Val)
}
func (r *Record) appendFields(dst []byte) []byte {
start := len(dst)
for i, f := range r.Fields {
if i == r.msgIdx {
continue
}
if len(dst) > start {
dst = append(dst, ' ')
}
dst = append(dst, f.Key...)
dst = append(dst, '=')
dst = r.appendDisplay(dst, f)
if len(dst)-start > fieldsTextCap {
break
}
}
return dst
}
// Display renders a field value: durations via the unit table, long float
// tails trimmed, everything else verbatim.
func (r *Record) Display(f Field) string {
return string(r.appendDisplay(nil, f))
}
func (r *Record) appendDisplay(dst []byte, f Field) []byte {
switch f.Kind {
case KStr:
return appendUnquoted(dst, f.Val)
case KNum:
if u := DurationUnit(f.Key); u != DurNone {
if v, ok := parseInt64(f.Val); ok {
return append(dst, FormatDuration(v, u)...)
}
}
return append(dst, trimFloat(f.Val)...)
}
return append(dst, f.Val...)
}
func appendUnquoted(dst, tok []byte) []byte {
in := strTok(tok)
if bytes.IndexByte(in, '\\') < 0 {
return append(dst, in...)
}
return append(dst, unquote(tok)...)
}
// trimFloat shortens 17-digit float tails to 6 significant digits.
func trimFloat(tok []byte) []byte {
dot := bytes.IndexByte(tok, '.')
if dot < 0 || len(tok)-dot <= 7 {
return tok
}
if v, err := strconv.ParseFloat(string(tok), 64); err == nil {
return strconv.AppendFloat(nil, v, 'g', 6, 64)
}
return tok
}