Files
ctrld/cmd/cli/change_logger.go
T
Anthony Wong 8ae09ac98e feat(cli): put the network state of the host in the log journal
The journal records the network state of the host and each change of
that state. The support team reads one file to see the interfaces, the
routes, the resolvers, and the recoveries around an incident. The same
file shows the health of the query path. A change that touches only
AirDrop or virtual interfaces runs no DNS work.
2026-09-28 16:49:05 +07:00

206 lines
5.7 KiB
Go

package cli
import (
"maps"
"slices"
"sync"
"tailscale.com/net/netmon"
"time"
)
// noiseSummaryInterval bounds a storm of noise deltas to one journal line,
// because AirDrop and virtual adapters can churn for hours.
const noiseSummaryInterval = 10 * time.Minute
// wakeReportInterval merges the reports of the wake sources. netmon polls for
// a time jump every 15 s, so one wake can reach ctrld over half a minute.
const wakeReportInterval = 30 * time.Second
// repeatLogger holds the last value of each named line, so a line that
// repeats every few seconds reaches the log on change only.
type repeatLogger struct {
mu sync.Mutex
last map[string]string
repeats map[string]uint64
}
// changed reports whether the value of a key differs from the value before it.
// The repeat count tells how many lines the caller held back, so the next line
// it writes can carry them.
func (r *repeatLogger) changed(key, value string) (changed bool, repeats uint64) {
r.mu.Lock()
defer r.mu.Unlock()
if r.last == nil {
r.last = make(map[string]string)
r.repeats = make(map[string]uint64)
}
if previous, seen := r.last[key]; seen && previous == value {
r.repeats[key]++
return false, r.repeats[key]
}
repeats = r.repeats[key]
r.last[key] = value
r.repeats[key] = 0
return true, repeats
}
// wakeHoldInterval is the time that a wake report without a gap waits for the
// detector. The detector ticks every two seconds, so it reports well inside
// this hold when it runs.
const wakeHoldInterval = 8 * time.Second
// wakeReport is one wake as a source saw it.
type wakeReport struct {
source string
gap time.Duration
state *netmon.State
}
// wakeReporter keeps one report per wake. netmon measures no gap, so its
// report waits for the detector, which measures one. When no detector report
// comes, the held report goes out as it is.
type wakeReporter struct {
mu sync.Mutex
last time.Time
held *wakeReport
// after arms the hold timer. nil means time.AfterFunc, and a test fires
// the hold itself.
after func(time.Duration, func())
}
// note takes one report of a wake and hands the report that the journal gets
// to emit. The first source of a window reports. A report with a gap replaces
// a held report without one. Every other report of the window is the same
// wake and drops.
func (w *wakeReporter) note(now time.Time, report wakeReport, emit func(wakeReport)) {
w.mu.Lock()
defer w.mu.Unlock()
gapKnown := report.gap > 0
if !w.last.IsZero() && now.Sub(w.last) < wakeReportInterval {
if w.held != nil && gapKnown {
w.held = nil
emit(report)
}
return
}
w.last = now
if gapKnown {
w.held = nil
emit(report)
return
}
held := &wakeReport{source: report.source, gap: report.gap, state: report.state}
w.held = held
w.afterFunc()(wakeHoldInterval, func() { w.release(held, emit) })
}
// release emits a held report when no source with a gap replaced it.
func (w *wakeReporter) release(held *wakeReport, emit func(wakeReport)) {
w.mu.Lock()
defer w.mu.Unlock()
if w.held != held {
return
}
w.held = nil
emit(*held)
}
func (w *wakeReporter) afterFunc() func(time.Duration, func()) {
if w.after != nil {
return w.after
}
return func(d time.Duration, fn func()) { time.AfterFunc(d, fn) }
}
// noiseSummary describes the noise deltas that one journal line covers.
type noiseSummary struct {
Count int
First time.Time
Last time.Time
Interfaces []string
}
// noiseCoalescer counts the noise deltas of a storm and reports when the
// journal needs a line for them. The count, the first time, and the names
// cover the open window; last covers the storm, so a quiet period ends it.
type noiseCoalescer struct {
mu sync.Mutex
count int
first time.Time
last time.Time
emitted time.Time
names map[string]struct{}
}
// add records one noise delta and reports whether to log a summary now. The
// summary covers the deltas since the last one, so a storm of any length
// keeps to one line per interval.
func (c *noiseCoalescer) add(interfaces []string, now time.Time) (summary noiseSummary, emit bool) {
c.mu.Lock()
defer c.mu.Unlock()
newRun := c.stormEnded(now)
if newRun {
c.clearWindow()
}
c.record(interfaces, now)
if !newRun && now.Sub(c.emitted) < noiseSummaryInterval {
return noiseSummary{}, false
}
summary = c.summary()
c.emitted = now
c.clearWindow()
return summary, true
}
// flush reports the open window and closes it. A storm ends with a delta that
// the daemon acts on, so the last window of the storm reaches the journal
// there instead of waiting for the next storm.
func (c *noiseCoalescer) flush(now time.Time) (summary noiseSummary, emit bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.count == 0 {
return noiseSummary{}, false
}
summary = c.summary()
c.emitted = now
c.clearWindow()
return summary, true
}
// stormEnded is true when the deltas stopped for longer than the interval, so
// a new storm does not inherit the count of the storm before it.
func (c *noiseCoalescer) stormEnded(now time.Time) bool {
return c.last.IsZero() || now.Sub(c.last) > noiseSummaryInterval
}
func (c *noiseCoalescer) record(interfaces []string, now time.Time) {
if c.count == 0 {
c.first = now
}
c.count++
c.last = now
if c.names == nil {
c.names = make(map[string]struct{})
}
for _, name := range interfaces {
c.names[name] = struct{}{}
}
}
// summary sorts the names, because a log line that keeps its order reads as
// one value across a capture.
func (c *noiseCoalescer) summary() noiseSummary {
return noiseSummary{
Count: c.count,
First: c.first,
Last: c.last,
Interfaces: slices.Sorted(maps.Keys(c.names)),
}
}
func (c *noiseCoalescer) clearWindow() {
c.count = 0
c.first = time.Time{}
c.names = nil
}