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)
}
}