logtree: implement raw logging

Test Plan: Covered by new tests.

X-Origin-Diff: phab/D640
GitOrigin-RevId: 786ab2851710bf2819dcb91571b3567e8da3e377
diff --git a/core/pkg/logtree/BUILD.bazel b/core/pkg/logtree/BUILD.bazel
index ed9fda4..68abcfb 100644
--- a/core/pkg/logtree/BUILD.bazel
+++ b/core/pkg/logtree/BUILD.bazel
@@ -15,6 +15,7 @@
     ],
     importpath = "git.monogon.dev/source/nexantic.git/core/pkg/logtree",
     visibility = ["//visibility:public"],
+    deps = ["//core/pkg/logbuffer:go_default_library"],
 )
 
 go_test(
diff --git a/core/pkg/logtree/journal.go b/core/pkg/logtree/journal.go
index fe6d80c..893eff0 100644
--- a/core/pkg/logtree/journal.go
+++ b/core/pkg/logtree/journal.go
@@ -106,20 +106,19 @@
 	}
 }
 
-// filter is a predicate that returns true if a log subscriber or reader is interested in a log entry at a given
-// severity and logged to a given DN.
-type filter func(origin DN, severity Severity) bool
+// filter is a predicate that returns true if a log subscriber or reader is interested in a given log entry.
+type filter func(*entry) bool
 
-// filterALl returns a filter that accepts all log entries.
+// filterAll returns a filter that accepts all log entries.
 func filterAll() filter {
-	return func(origin DN, _ Severity) bool { return true }
+	return func(*entry) bool { return true }
 }
 
 // filterExact returns a filter that accepts only log entries at a given exact DN. This filter should not be used in
 // conjunction with journal.scanEntries - instead, journal.getEntries should be used, as it is much faster.
 func filterExact(dn DN) filter {
-	return func(origin DN, _ Severity) bool {
-		return origin == dn
+	return func(e *entry) bool {
+		return e.origin == dn
 	}
 }
 
@@ -131,8 +130,8 @@
 	}
 
 	rootParts := strings.Split(string(root), ".")
-	return func(origin DN, _ Severity) bool {
-		parts := strings.Split(string(origin), ".")
+	return func(e *entry) bool {
+		parts := strings.Split(string(e.origin), ".")
 		if len(parts) < len(rootParts) {
 			return false
 		}
@@ -150,11 +149,19 @@
 // filterSeverity returns a filter that accepts log entries at a given severity level or above. See the Severity type
 // for more information about severity levels.
 func filterSeverity(atLeast Severity) filter {
-	return func(origin DN, s Severity) bool {
-		return s.AtLeast(atLeast)
+	return func(e *entry) bool {
+		return e.leveled != nil && e.leveled.severity.AtLeast(atLeast)
 	}
 }
 
+func filterOnlyRaw(e *entry) bool {
+	return e.raw != nil
+}
+
+func filterOnlyLeveled(e *entry) bool {
+	return e.leveled != nil
+}
+
 // scanEntries does a linear scan through the global entry list and returns all entries that match the given filters. If
 // retrieving entries for an exact event, getEntries should be used instead, as it will leverage DN-local linked lists
 // to retrieve them faster.
@@ -168,7 +175,7 @@
 
 		passed := true
 		for _, filter := range filters {
-			if !filter(cur.origin, cur.leveled.severity) {
+			if !filter(cur) {
 				passed = false
 				break
 			}
@@ -193,7 +200,7 @@
 
 		passed := true
 		for _, filter := range filters {
-			if !filter(cur.origin, cur.leveled.severity) {
+			if !filter(cur) {
 				passed = false
 				break
 			}
diff --git a/core/pkg/logtree/journal_entry.go b/core/pkg/logtree/journal_entry.go
index 58b21f5..bc65ef3 100644
--- a/core/pkg/logtree/journal_entry.go
+++ b/core/pkg/logtree/journal_entry.go
@@ -16,6 +16,8 @@
 
 package logtree
 
+import "git.monogon.dev/source/nexantic.git/core/pkg/logbuffer"
+
 // entry is a journal entry, representing a single log event (encompassed in a Payload) at a given DN.
 // See the journal struct for more information about the global/local linked lists.
 type entry struct {
@@ -24,8 +26,11 @@
 	// journal is the parent journal of this entry. An entry can belong only to a single journal. This pointer is used
 	// to mutate the journal's head/tail pointers when unlinking an entry.
 	journal *journal
-	// payload is the inner log entry LeveledPayload. It contains all data and metadata received from the log producer.
+	// leveled is the leveled log entry for this entry, if this log entry was emitted by leveled logging. Otherwise it
+	// is nil.
 	leveled *LeveledPayload
+	// raw is the raw log entry for this entry, if this log entry was emitted by raw logging. Otherwise it is nil.
+	raw *logbuffer.Line
 
 	// prevGlobal is the previous entry in the global linked list, or nil if this entry is the oldest entry in the
 	// global linked list.
@@ -48,6 +53,16 @@
 	seqLocal uint64
 }
 
+// external returns a LogEntry object for this entry, ie. the public version of this object, without fields relating to
+// the parent journal, linked lists, sequences, etc. These objects are visible to library consumers.
+func (e *entry) external() *LogEntry {
+	return &LogEntry{
+		DN:      e.origin,
+		Leveled: e.leveled,
+		Raw:     e.raw,
+	}
+}
+
 // unlink removes this entry from both global and local linked lists, updating the journal's head/tail pointers if
 // needed.
 // journal.mu must be taken as RW
diff --git a/core/pkg/logtree/journal_subscriber.go b/core/pkg/logtree/journal_subscriber.go
index d6a91db..e6c7c62 100644
--- a/core/pkg/logtree/journal_subscriber.go
+++ b/core/pkg/logtree/journal_subscriber.go
@@ -55,12 +55,12 @@
 		}
 
 		for _, filter := range sub.filters {
-			if !filter(e.origin, e.leveled.severity) {
+			if !filter(e) {
 				continue
 			}
 		}
 		select {
-		case sub.dataC <- &LogEntry{LeveledPayload: e.leveled, DN: e.origin}:
+		case sub.dataC <- e.external():
 		default:
 			atomic.AddUint64(&sub.missed, 1)
 		}
diff --git a/core/pkg/logtree/logtree.go b/core/pkg/logtree/logtree.go
index 4d674fd..064b6e7 100644
--- a/core/pkg/logtree/logtree.go
+++ b/core/pkg/logtree/logtree.go
@@ -20,9 +20,11 @@
 	"fmt"
 	"strings"
 	"sync"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/logbuffer"
 )
 
-// LogTree is a tree-shapped logging system. For more information, see the package-level documentation.
+// LogTree is a tree-shaped logging system. For more information, see the package-level documentation.
 type LogTree struct {
 	// journal is the tree's journal, storing all log data and managing subscribers.
 	journal *journal
@@ -39,7 +41,7 @@
 	return lt
 }
 
-// node represents a given DN as a discrete 'logger'. It implementes the LeveledLogger interface for log publishing,
+// node represents a given DN as a discrete 'logger'. It implements the LeveledLogger interface for log publishing,
 // entries from which it passes over to the logtree's journal.
 type node struct {
 	// dn is the DN which this node represents (or "" if this is the root node).
@@ -47,7 +49,8 @@
 	// tree is the LogTree to which this node belongs.
 	tree *LogTree
 	// verbosity is the current verbosity level of this DN/node, affecting .V(n) LeveledLogger calls
-	verbosity VerbosityLevel
+	verbosity     VerbosityLevel
+	rawLineBuffer *logbuffer.LineBuffer
 
 	// mu guards children.
 	mu sync.Mutex
@@ -63,6 +66,7 @@
 		tree:     tree,
 		children: make(map[string]*node),
 	}
+	n.rawLineBuffer = logbuffer.NewLineBuffer(1024, n.logRaw)
 	return n
 }
 
diff --git a/core/pkg/logtree/logtree_access.go b/core/pkg/logtree/logtree_access.go
index c10aa7b..ee93df2 100644
--- a/core/pkg/logtree/logtree_access.go
+++ b/core/pkg/logtree/logtree_access.go
@@ -16,14 +16,22 @@
 
 package logtree
 
-import "sync/atomic"
+import (
+	"fmt"
+	"log"
+	"sync/atomic"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/logbuffer"
+)
 
 // LogReadOption describes options for the LogTree.Read call.
 type LogReadOption struct {
-	withChildren        bool
-	withStream          bool
-	withBacklog         int
-	withMinimumSeverity Severity
+	withChildren               bool
+	withStream                 bool
+	withBacklog                int
+	onlyLeveled                bool
+	onlyRaw                    bool
+	leveledWithMinimumSeverity Severity
 }
 
 // WithChildren makes Read return/stream data for both a given DN and all its children.
@@ -39,9 +47,14 @@
 // BacklogAllAvailable makes WithBacklog return all backlogged log data that logtree possesses.
 const BacklogAllAvailable int = -1
 
-// WithMinimumSeverity makes Read return only log entries that are at least at a given Severity.
-func WithMinimumSeverity(s Severity) LogReadOption {
-	return LogReadOption{withMinimumSeverity: s}
+func OnlyRaw() LogReadOption { return LogReadOption{onlyRaw: true} }
+
+func OnlyLeveled() LogReadOption { return LogReadOption{onlyLeveled: true} }
+
+// LeveledWithMinimumSeverity makes Read return only log entries that are at least at a given Severity. If only leveled
+// entries are needed, OnlyLeveled must be used. This is a no-op when OnlyRaw is used.
+func LeveledWithMinimumSeverity(s Severity) LogReadOption {
+	return LogReadOption{leveledWithMinimumSeverity: s}
 }
 
 // LogReader permits reading an already existing backlog of log entries and to stream further ones.
@@ -77,22 +90,29 @@
 	}
 }
 
+// LogEntry contains a log entry, combining both leveled and raw logging into a single stream of events. A LogEntry
+// will contain exactly one of either LeveledPayload or RawPayload.
 type LogEntry struct {
-	*LeveledPayload
+	// If non-nil, this is a leveled logging entry.
+	Leveled *LeveledPayload
+	// If non-nil, this is a raw logging entry line.
+	Raw *logbuffer.Line
+	// DN from which this entry was logged.
 	DN DN
 }
 
 // Read and/or stream entries from a LogTree. The returned LogReader is influenced by the LogReadOptions passed, which
 // influence whether the Read will return existing entries, a stream, or both. In addition the options also dictate
 // whether only entries for that particular DN are returned, or for all sub-DNs as well.
-func (l *LogTree) Read(dn DN, opts ...LogReadOption) *LogReader {
+func (l *LogTree) Read(dn DN, opts ...LogReadOption) (*LogReader, error) {
 	l.journal.mu.RLock()
 	defer l.journal.mu.RUnlock()
 
 	var backlog int
 	var stream bool
 	var recursive bool
-	var severity Severity
+	var leveledSeverity Severity
+	var onlyRaw, onlyLeveled bool
 
 	for _, opt := range opts {
 		if opt.withBacklog > 0 || opt.withBacklog == BacklogAllAvailable {
@@ -104,19 +124,35 @@
 		if opt.withChildren {
 			recursive = true
 		}
-		if opt.withMinimumSeverity != "" {
-			severity = opt.withMinimumSeverity
+		if opt.leveledWithMinimumSeverity != "" {
+			leveledSeverity = opt.leveledWithMinimumSeverity
+		}
+		if opt.onlyLeveled {
+			onlyLeveled = true
+		}
+		if opt.onlyRaw {
+			onlyRaw = true
 		}
 	}
 
+	if onlyLeveled && onlyRaw {
+		return nil, fmt.Errorf("cannot return logs that are simultaneously OnlyRaw and OnlyLeveled")
+	}
+
 	var filters []filter
+	if onlyLeveled {
+		filters = append(filters, filterOnlyLeveled)
+	}
+	if onlyRaw {
+		filters = append(filters, filterOnlyRaw)
+	}
 	if recursive {
 		filters = append(filters, filterSubtree(dn))
 	} else {
 		filters = append(filters, filterExact(dn))
 	}
-	if severity != "" {
-		filters = append(filters, filterSeverity(severity))
+	if leveledSeverity != "" {
+		filters = append(filters, filterSeverity(leveledSeverity))
 	}
 
 	var entries []*entry
@@ -146,12 +182,13 @@
 	lr := &LogReader{}
 	lr.Backlog = make([]*LogEntry, len(entries))
 	for i, entry := range entries {
-		lr.Backlog[i] = &LogEntry{LeveledPayload: entry.leveled, DN: entry.origin}
+		log.Printf("backlog %d %+v %+v", i, entry.raw, entry.leveled)
+		lr.Backlog[i] = entry.external()
 	}
 	if stream {
 		lr.Stream = sub.dataC
 		lr.done = sub.doneC
 		lr.missed = &sub.missed
 	}
-	return lr
+	return lr, nil
 }
diff --git a/core/pkg/logtree/logtree_publisher.go b/core/pkg/logtree/logtree_publisher.go
index bc47bc6..3a81ec6 100644
--- a/core/pkg/logtree/logtree_publisher.go
+++ b/core/pkg/logtree/logtree_publisher.go
@@ -18,9 +18,12 @@
 
 import (
 	"fmt"
+	"io"
 	"runtime"
 	"strings"
 	"time"
+
+	"git.monogon.dev/source/nexantic.git/core/pkg/logbuffer"
 )
 
 // LeveledFor returns a LeveledLogger publishing interface for a given DN. An error may be returned if the DN is
@@ -29,6 +32,14 @@
 	return l.nodeByDN(dn)
 }
 
+func (l *LogTree) RawFor(dn DN) (io.Writer, error) {
+	node, err := l.nodeByDN(dn)
+	if err != nil {
+		return nil, fmt.Errorf("could not retrieve raw logger: %w", err)
+	}
+	return node.rawLineBuffer, nil
+}
+
 // MustLeveledFor returns a LeveledLogger publishing interface for a given DN, or panics if the given DN is invalid.
 func (l *LogTree) MustLeveledFor(dn DN) LeveledLogger {
 	leveled, err := l.LeveledFor(dn)
@@ -38,6 +49,14 @@
 	return leveled
 }
 
+func (l *LogTree) MustRawFor(dn DN) io.Writer {
+	raw, err := l.RawFor(dn)
+	if err != nil {
+		panic(fmt.Errorf("RawFor returned: %w", err))
+	}
+	return raw
+}
+
 // SetVerbosity sets the verbosity for a given DN (non-recursively, ie. for that DN only, not its children).
 func (l *LogTree) SetVerbosity(dn DN, level VerbosityLevel) error {
 	node, err := l.nodeByDN(dn)
@@ -48,9 +67,20 @@
 	return nil
 }
 
-// log builds a Payload and entry for a given message, including all related metadata, and appends it to the journal,
-// notifying all pertinent subscribers.
-func (n *node) log(depth int, severity Severity, msg string) {
+// logRaw is called by this node's LineBuffer any time a raw log line is completed. It will create a new entry, append
+// it to the journal, and notify all pertinent subscribers.
+func (n *node) logRaw(line *logbuffer.Line) {
+	e := &entry{
+		origin: n.dn,
+		raw:    line,
+	}
+	n.tree.journal.append(e)
+	n.tree.journal.notify(e)
+}
+
+// log builds a LeveledPayload and entry for a given message, including all related metadata. It will create a new
+// entry append it to the journal, and notify all pertinent subscribers.
+func (n *node) logLeveled(depth int, severity Severity, msg string) {
 	_, file, line, ok := runtime.Caller(2 + depth)
 	if !ok {
 		file = "???"
@@ -78,42 +108,42 @@
 
 // Info implements the LeveledLogger interface.
 func (n *node) Info(args ...interface{}) {
-	n.log(0, INFO, fmt.Sprint(args...))
+	n.logLeveled(0, INFO, fmt.Sprint(args...))
 }
 
 // Infof implements the LeveledLogger interface.
 func (n *node) Infof(format string, args ...interface{}) {
-	n.log(0, INFO, fmt.Sprintf(format, args...))
+	n.logLeveled(0, INFO, fmt.Sprintf(format, args...))
 }
 
 // Warning implements the LeveledLogger interface.
 func (n *node) Warning(args ...interface{}) {
-	n.log(0, WARNING, fmt.Sprint(args...))
+	n.logLeveled(0, WARNING, fmt.Sprint(args...))
 }
 
 // Warningf implements the LeveledLogger interface.
 func (n *node) Warningf(format string, args ...interface{}) {
-	n.log(0, WARNING, fmt.Sprintf(format, args...))
+	n.logLeveled(0, WARNING, fmt.Sprintf(format, args...))
 }
 
 // Error implements the LeveledLogger interface.
 func (n *node) Error(args ...interface{}) {
-	n.log(0, ERROR, fmt.Sprint(args...))
+	n.logLeveled(0, ERROR, fmt.Sprint(args...))
 }
 
 // Errorf implements the LeveledLogger interface.
 func (n *node) Errorf(format string, args ...interface{}) {
-	n.log(0, ERROR, fmt.Sprintf(format, args...))
+	n.logLeveled(0, ERROR, fmt.Sprintf(format, args...))
 }
 
 // Fatal implements the LeveledLogger interface.
 func (n *node) Fatal(args ...interface{}) {
-	n.log(0, FATAL, fmt.Sprint(args...))
+	n.logLeveled(0, FATAL, fmt.Sprint(args...))
 }
 
 // Fatalf implements the LeveledLogger interface.
 func (n *node) Fatalf(format string, args ...interface{}) {
-	n.log(0, FATAL, fmt.Sprintf(format, args...))
+	n.logLeveled(0, FATAL, fmt.Sprintf(format, args...))
 }
 
 // V implements the LeveledLogger interface.
@@ -140,12 +170,12 @@
 	if !v.enabled {
 		return
 	}
-	v.node.log(0, INFO, fmt.Sprint(args...))
+	v.node.logLeveled(0, INFO, fmt.Sprint(args...))
 }
 
 func (v *verbose) Infof(format string, args ...interface{}) {
 	if !v.enabled {
 		return
 	}
-	v.node.log(0, INFO, fmt.Sprintf(format, args...))
+	v.node.logLeveled(0, INFO, fmt.Sprintf(format, args...))
 }
diff --git a/core/pkg/logtree/logtree_test.go b/core/pkg/logtree/logtree_test.go
index 3e5dfba..f1465ad 100644
--- a/core/pkg/logtree/logtree_test.go
+++ b/core/pkg/logtree/logtree_test.go
@@ -18,6 +18,7 @@
 
 import (
 	"fmt"
+	"log"
 	"strings"
 	"testing"
 	"time"
@@ -29,15 +30,25 @@
 	tree.MustLeveledFor("main.foo").Info("hello, main.foo!")
 	tree.MustLeveledFor("main.bar").Info("hello, main.bar!")
 	tree.MustLeveledFor("aux").Info("hello, aux!")
+	// No newline at the last entry - shouldn't get propagated to the backlog.
+	fmt.Fprintf(tree.MustRawFor("aux.process"), "processing foo\nprocessing bar\nbaz")
 
 	expect := func(dn DN, entries ...string) string {
-		res := tree.Read(dn, WithChildren(), WithBacklog(BacklogAllAvailable))
+		res, err := tree.Read(dn, WithChildren(), WithBacklog(BacklogAllAvailable))
+		if err != nil {
+			t.Fatalf("Read: %v", err)
+		}
 		if want, got := len(entries), len(res.Backlog); want != got {
 			t.Fatalf("wanted %d backlog entries, got %d", want, got)
 		}
 		got := make(map[string]bool)
 		for _, entry := range res.Backlog {
-			got[entry.Message()] = true
+			if entry.Leveled != nil {
+				got[entry.Leveled.Message()] = true
+			}
+			if entry.Raw != nil {
+				got[entry.Raw.Data] = true
+			}
 		}
 		for _, entry := range entries {
 			if !got[entry] {
@@ -50,10 +61,10 @@
 	if res := expect("main", "hello, main!", "hello, main.foo!", "hello, main.bar!"); res != "" {
 		t.Errorf("retrieval at main failed: %s", res)
 	}
-	if res := expect("", "hello, main!", "hello, main.foo!", "hello, main.bar!", "hello, aux!"); res != "" {
+	if res := expect("", "hello, main!", "hello, main.foo!", "hello, main.bar!", "hello, aux!", "processing foo", "processing bar"); res != "" {
 		t.Errorf("retrieval at root failed: %s", res)
 	}
-	if res := expect("aux", "hello, aux!"); res != "" {
+	if res := expect("aux", "hello, aux!", "processing foo", "processing bar"); res != "" {
 		t.Errorf("retrieval at aux failed: %s", res)
 	}
 }
@@ -61,22 +72,46 @@
 func TestStream(t *testing.T) {
 	tree := New()
 	tree.MustLeveledFor("main").Info("hello, backlog")
+	fmt.Fprintf(tree.MustRawFor("main.process"), "hello, raw backlog\n")
 
-	res := tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren(), WithStream())
+	log.Printf("read start")
+	res, err := tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren(), WithStream())
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
+	log.Printf("read done")
 	defer res.Close()
-	if want, got := 1, len(res.Backlog); want != got {
+	if want, got := 2, len(res.Backlog); want != got {
 		t.Errorf("wanted %d backlog item, got %d", want, got)
 	}
 
 	tree.MustLeveledFor("main").Info("hello, stream")
+	fmt.Fprintf(tree.MustRawFor("main.raw"), "hello, raw stream\n")
 
-	select {
-	case <-time.After(time.Second * 1):
-		t.Fatalf("timeout elapsed")
-	case p := <-res.Stream:
-		if want, got := "hello, stream", p.Message(); want != got {
-			t.Fatalf("stream returned %q, wanted %q", got, want)
+	entries := make(map[string]bool)
+	timeout := time.After(time.Second * 1)
+	for {
+		done := false
+		select {
+		case <-timeout:
+			done = true
+		case p := <-res.Stream:
+			if p.Leveled != nil {
+				entries[p.Leveled.Message()] = true
+			}
+			if p.Raw != nil {
+				entries[p.Raw.Data] = true
+			}
 		}
+		if done {
+			break
+		}
+	}
+	if entry := "hello, stream"; !entries[entry] {
+		t.Errorf("Missing entry %q", entry)
+	}
+	if entry := "hello, raw stream"; !entries[entry] {
+		t.Errorf("Missing entry %q", entry)
 	}
 }
 
@@ -85,7 +120,10 @@
 
 	tree.MustLeveledFor("main").V(10).Info("this shouldn't get logged")
 
-	reader := tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren())
+	reader, err := tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren())
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
 	if want, got := 0, len(reader.Backlog); want != got {
 		t.Fatalf("expected nothing to be logged, got %+v", reader.Backlog)
 	}
@@ -93,7 +131,10 @@
 	tree.SetVerbosity("main", 10)
 	tree.MustLeveledFor("main").V(10).Info("this should get logged")
 
-	reader = tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren())
+	reader, err = tree.Read("", WithBacklog(BacklogAllAvailable), WithChildren())
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
 	if want, got := 1, len(reader.Backlog); want != got {
 		t.Fatalf("expected %d entries to get logged, got %d", want, got)
 	}
@@ -106,7 +147,10 @@
 	tree.MustLeveledFor("main").Info("i am informative")
 	tree.MustLeveledFor("main").V(0).Info("i am a zero-level debug")
 
-	reader := tree.Read("", WithChildren(), WithBacklog(BacklogAllAvailable))
+	reader, err := tree.Read("", WithChildren(), WithBacklog(BacklogAllAvailable))
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
 	if want, got := 4, len(reader.Backlog); want != got {
 		t.Fatalf("expected %d entries, got %d", want, got)
 	}
@@ -122,13 +166,13 @@
 		{3, INFO, "i am a zero-level debug"},
 	} {
 		p := reader.Backlog[te.ix]
-		if want, got := te.severity, p.Severity(); want != got {
+		if want, got := te.severity, p.Leveled.Severity(); want != got {
 			t.Errorf("wanted element %d to have severity %s, got %s", te.ix, want, got)
 		}
-		if want, got := te.message, p.Message(); want != got {
+		if want, got := te.message, p.Leveled.Message(); want != got {
 			t.Errorf("wanted element %d to have message %q, got %q", te.ix, want, got)
 		}
-		if want, got := "logtree_test.go", strings.Split(p.Location(), ":")[0]; want != got {
+		if want, got := "logtree_test.go", strings.Split(p.Leveled.Location(), ":")[0]; want != got {
 			t.Errorf("wanted element %d to have file %q, got %q", te.ix, want, got)
 		}
 	}
@@ -141,14 +185,17 @@
 	tree.MustLeveledFor("main").Info("i am informative")
 	tree.MustLeveledFor("main").V(0).Info("i am a zero-level debug")
 
-	reader := tree.Read("main", WithBacklog(BacklogAllAvailable), WithMinimumSeverity(WARNING))
+	reader, err := tree.Read("main", WithBacklog(BacklogAllAvailable), LeveledWithMinimumSeverity(WARNING))
+	if err != nil {
+		t.Fatalf("Read: %v", err)
+	}
 	if want, got := 2, len(reader.Backlog); want != got {
 		t.Fatalf("wanted %d entries, got %d", want, got)
 	}
-	if want, got := "i am an error", reader.Backlog[0].Message(); want != got {
+	if want, got := "i am an error", reader.Backlog[0].Leveled.Message(); want != got {
 		t.Fatalf("wanted entry %q, got %q", want, got)
 	}
-	if want, got := "i am a warning", reader.Backlog[1].Message(); want != got {
+	if want, got := "i am a warning", reader.Backlog[1].Leveled.Message(); want != got {
 		t.Fatalf("wanted entry %q, got %q", want, got)
 	}
 }