mirror of
https://github.com/shtorm-7/sing-box-extended.git
synced 2026-10-21 00:34:24 +00:00
864 lines
24 KiB
Go
864 lines
24 KiB
Go
package powerreport
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime/metrics"
|
|
"runtime/pprof"
|
|
"slices"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
"github.com/sagernet/sing/common/logger"
|
|
"github.com/sagernet/sing/common/memory"
|
|
"github.com/sagernet/sing/service/pause"
|
|
)
|
|
|
|
const (
|
|
DraftDirectoryName = "power_draft"
|
|
ReportsDirectoryName = "power_reports"
|
|
|
|
timelineFileName = "timeline.jsonl"
|
|
eventsFileName = "events.jsonl"
|
|
metadataFileName = "metadata.json"
|
|
logFileName = "go.log"
|
|
goroutineProfileFileName = "goroutine.pb.gz"
|
|
|
|
defaultGateInterval = 5 * time.Second
|
|
defaultSampleInterval = time.Minute
|
|
defaultFlushInterval = 15 * time.Minute
|
|
defaultFallbackInterval = 10 * time.Minute
|
|
|
|
activityRefreshNano = int64(time.Second)
|
|
|
|
rowCapacity = 4096
|
|
eventCapacity = 8192
|
|
|
|
maxDNSDomains = 512
|
|
maxRowDNSDomains = 8
|
|
maxProfiles = 3
|
|
profileMinInterval = 2 * time.Minute
|
|
profileMaxDuration = 10 * time.Second
|
|
)
|
|
|
|
type Options struct {
|
|
BasePath string
|
|
Logger logger.Logger
|
|
Metadata any
|
|
OwnerCallback func(path string)
|
|
LogCallback func() []byte
|
|
ProfileCallback func(path string)
|
|
GateInterval time.Duration
|
|
SampleInterval time.Duration
|
|
FlushInterval time.Duration
|
|
FallbackInterval time.Duration
|
|
}
|
|
|
|
type Recorder struct {
|
|
draftPath string
|
|
logger logger.Logger
|
|
metadata any
|
|
ownerCallback func(path string)
|
|
logCallback func() []byte
|
|
profileCallback func(path string)
|
|
gateNano int64
|
|
sampleNano int64
|
|
flushInterval time.Duration
|
|
fallbackInterval time.Duration
|
|
baseTime time.Time
|
|
|
|
_ [64]byte
|
|
lastActivity atomic.Int64
|
|
lastActivityWall atomic.Int64
|
|
_ [64]byte
|
|
|
|
wakePending atomic.Bool
|
|
lastWakeNano atomic.Int64
|
|
lastSampleAt atomic.Int64
|
|
pendingBreak atomic.Pointer[breakRecord]
|
|
notify chan struct{}
|
|
|
|
dnsQueries atomic.Uint64
|
|
connectionsOpened atomic.Uint64
|
|
networkPathUpdates atomic.Uint64
|
|
|
|
trafficAccess sync.RWMutex
|
|
traffic map[trafficKey]*TrafficCounter
|
|
|
|
dnsAccess sync.Mutex
|
|
dnsDomains map[string]uint64
|
|
|
|
access sync.Mutex
|
|
networkType string
|
|
networkPath string
|
|
rows []timelineRow
|
|
events []eventRecord
|
|
previous previousSample
|
|
lastFlushAt time.Time
|
|
started bool
|
|
closed bool
|
|
metricsSamples []metrics.Sample
|
|
lastDeviceState deviceState
|
|
deviceStateValid bool
|
|
profiles []profileEntry
|
|
lastProfileAt time.Time
|
|
profileFile *os.File
|
|
profileTimer *time.Timer
|
|
awake bool
|
|
wakeAt time.Time
|
|
wakeUsage systemUsage
|
|
wakeDNSQueries uint64
|
|
wakeConnections uint64
|
|
windowRate float64
|
|
|
|
done chan struct{}
|
|
workerDone chan struct{}
|
|
}
|
|
|
|
type breakRecord struct {
|
|
at time.Time
|
|
idleMS int64
|
|
wallIdleMS int64
|
|
afterWake bool
|
|
sinceWakeMS int64
|
|
direction Direction
|
|
size int
|
|
by *Attribution
|
|
}
|
|
|
|
type previousSample struct {
|
|
at time.Time
|
|
usage systemUsage
|
|
gcSeconds float64
|
|
gcCycles uint64
|
|
absoluteTime int64
|
|
continuousTime int64
|
|
interfaces map[string]interfaceCounters
|
|
dnsQueries uint64
|
|
connectionsOpened uint64
|
|
networkPathUpdates uint64
|
|
traffic map[trafficKey]trafficBytes
|
|
}
|
|
|
|
type profileEntry struct {
|
|
rate float64
|
|
path string
|
|
}
|
|
|
|
type profileRequest struct {
|
|
writePath string
|
|
removePath string
|
|
}
|
|
|
|
func NewRecorder(options Options) *Recorder {
|
|
recorderLogger := options.Logger
|
|
if recorderLogger == nil {
|
|
recorderLogger = logger.NOP()
|
|
}
|
|
gateInterval := options.GateInterval
|
|
if gateInterval == 0 {
|
|
gateInterval = defaultGateInterval
|
|
}
|
|
sampleInterval := options.SampleInterval
|
|
if sampleInterval == 0 {
|
|
sampleInterval = defaultSampleInterval
|
|
}
|
|
flushInterval := options.FlushInterval
|
|
if flushInterval == 0 {
|
|
flushInterval = defaultFlushInterval
|
|
}
|
|
fallbackInterval := options.FallbackInterval
|
|
if fallbackInterval == 0 {
|
|
fallbackInterval = defaultFallbackInterval
|
|
}
|
|
return &Recorder{
|
|
draftPath: filepath.Join(options.BasePath, DraftDirectoryName),
|
|
logger: recorderLogger,
|
|
metadata: options.Metadata,
|
|
ownerCallback: options.OwnerCallback,
|
|
logCallback: options.LogCallback,
|
|
profileCallback: options.ProfileCallback,
|
|
gateNano: int64(gateInterval),
|
|
sampleNano: int64(sampleInterval),
|
|
flushInterval: flushInterval,
|
|
fallbackInterval: fallbackInterval,
|
|
baseTime: time.Now(),
|
|
notify: make(chan struct{}, 1),
|
|
traffic: make(map[trafficKey]*TrafficCounter),
|
|
dnsDomains: make(map[string]uint64),
|
|
metricsSamples: []metrics.Sample{
|
|
{Name: "/cpu/classes/gc/total:cpu-seconds"},
|
|
{Name: "/sched/goroutines:goroutines"},
|
|
{Name: "/gc/cycles/total:gc-cycles"},
|
|
{Name: "/memory/classes/total:bytes"},
|
|
{Name: "/gc/heap/live:bytes"},
|
|
},
|
|
done: make(chan struct{}),
|
|
workerDone: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) Start() error {
|
|
r.access.Lock()
|
|
defer r.access.Unlock()
|
|
if r.started {
|
|
return nil
|
|
}
|
|
DiscardDraft(filepath.Dir(r.draftPath))
|
|
err := os.MkdirAll(r.draftPath, 0o777)
|
|
if err != nil {
|
|
return E.Cause(err, "create power report draft directory")
|
|
}
|
|
r.chown(r.draftPath)
|
|
if r.metadata != nil {
|
|
metadataContent, marshalErr := json.Marshal(r.metadata)
|
|
if marshalErr == nil {
|
|
metadataPath := filepath.Join(r.draftPath, metadataFileName)
|
|
os.WriteFile(metadataPath, metadataContent, 0o666)
|
|
r.chown(metadataPath)
|
|
}
|
|
}
|
|
now := time.Now()
|
|
r.resetPreviousLocked(now)
|
|
r.lastSampleAt.Store(int64(now.Sub(r.baseTime)))
|
|
r.lastFlushAt = now
|
|
r.started = true
|
|
r.beginWakeWindowLocked(now)
|
|
r.updateDeviceStateLocked(now)
|
|
go r.worker()
|
|
return nil
|
|
}
|
|
|
|
func (r *Recorder) Close() error {
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return nil
|
|
}
|
|
r.closed = true
|
|
r.access.Unlock()
|
|
close(r.done)
|
|
<-r.workerDone
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
r.stopProfileLocked()
|
|
r.consumeBreakLocked()
|
|
r.sampleLocked(now)
|
|
r.flushLocked(now)
|
|
r.access.Unlock()
|
|
r.writeProfiles()
|
|
r.writeLog()
|
|
finalizeDraft(r.draftPath)
|
|
return nil
|
|
}
|
|
|
|
func (r *Recorder) writeLog() {
|
|
if r.logCallback == nil {
|
|
return
|
|
}
|
|
content := r.logCallback()
|
|
if len(content) == 0 {
|
|
return
|
|
}
|
|
logPath := filepath.Join(r.draftPath, logFileName)
|
|
err := os.WriteFile(logPath, content, 0o666)
|
|
if err != nil {
|
|
return
|
|
}
|
|
r.chown(logPath)
|
|
}
|
|
|
|
// Touch reports one I/O activity: one read or write call, or one batched receive or send
|
|
// syscall on paths that batch packets. size is the size of the first packet of the activity
|
|
// and characterizes what ended an idle period; it is not accumulated. Volume totals come from
|
|
// the sampled interface counters instead.
|
|
func (r *Recorder) Touch(direction Direction, size int, by *Attribution) {
|
|
now := time.Now()
|
|
nowNano := int64(now.Sub(r.baseTime))
|
|
if r.wakePending.Load() && r.wakePending.CompareAndSwap(true, false) {
|
|
record := r.newBreakRecord(now, nowNano, direction, size, by)
|
|
record.afterWake = true
|
|
record.sinceWakeMS = (nowNano - r.lastWakeNano.Load()) / int64(time.Millisecond)
|
|
r.pendingBreak.Store(record)
|
|
r.notifyWorker()
|
|
return
|
|
}
|
|
lastNano := r.lastActivity.Load()
|
|
if nowNano-lastNano < activityRefreshNano {
|
|
return
|
|
}
|
|
record := r.newBreakRecord(now, nowNano, direction, size, by)
|
|
if record.idleMS >= r.gateNano/int64(time.Millisecond) {
|
|
r.pendingBreak.Store(record)
|
|
r.notifyWorker()
|
|
} else if nowNano-r.lastSampleAt.Load() >= r.sampleNano {
|
|
r.notifyWorker()
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) newBreakRecord(now time.Time, nowNano int64, direction Direction, size int, by *Attribution) *breakRecord {
|
|
previousNano := r.lastActivity.Swap(nowNano)
|
|
previousWall := r.lastActivityWall.Swap(now.UnixNano())
|
|
record := &breakRecord{
|
|
at: now,
|
|
direction: direction,
|
|
size: size,
|
|
by: by,
|
|
}
|
|
if previousNano != 0 {
|
|
record.idleMS = (nowNano - previousNano) / int64(time.Millisecond)
|
|
record.wallIdleMS = (now.UnixNano() - previousWall) / int64(time.Millisecond)
|
|
}
|
|
return record
|
|
}
|
|
|
|
func (r *Recorder) CountDNSQuery(domain string) {
|
|
r.dnsQueries.Add(1)
|
|
if domain == "" {
|
|
return
|
|
}
|
|
r.dnsAccess.Lock()
|
|
count, found := r.dnsDomains[domain]
|
|
if found || len(r.dnsDomains) < maxDNSDomains {
|
|
r.dnsDomains[domain] = count + 1
|
|
}
|
|
r.dnsAccess.Unlock()
|
|
}
|
|
|
|
func (r *Recorder) RecordPauseEvent(event int) {
|
|
switch event {
|
|
case pause.EventDevicePaused:
|
|
r.recordPlatformEvent(eventTypeDevicePause)
|
|
case pause.EventDeviceWake:
|
|
r.recordPlatformEvent(eventTypeDeviceWake)
|
|
case pause.EventNetworkPause:
|
|
r.recordPlatformEvent(eventTypeNetworkPause)
|
|
case pause.EventNetworkWake:
|
|
r.recordPlatformEvent(eventTypeNetworkWake)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) CountConnectionOpened() {
|
|
r.connectionsOpened.Add(1)
|
|
}
|
|
|
|
// The service log prefixes each line with whole monotonic seconds since createdAt, so a
|
|
// line at [N] sits at logBaseMonoMS + N*1000 on the monoMS axis of events and timeline rows.
|
|
func (r *Recorder) RecordServiceStart(createdAt time.Time, reload bool) {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
event := r.newEvent(eventTypeService, now)
|
|
event.LogBaseMonoMS = int64(createdAt.Sub(r.baseTime) / time.Millisecond)
|
|
event.Reload = reload
|
|
r.events = append(r.events, event)
|
|
r.access.Unlock()
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) RecordDeviceSleep() {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.wakePending.Store(false)
|
|
r.stopProfileLocked()
|
|
event := r.newEvent(eventTypeSleep, now)
|
|
if r.awake {
|
|
event.Window = r.endWakeWindowLocked(now)
|
|
}
|
|
r.events = append(r.events, event)
|
|
r.access.Unlock()
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) RecordDeviceWake() {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.events = append(r.events, r.newEvent(eventTypeWake, now))
|
|
var request *profileRequest
|
|
if !r.awake {
|
|
r.beginWakeWindowLocked(now)
|
|
r.lastWakeNano.Store(int64(now.Sub(r.baseTime)))
|
|
r.wakePending.Store(true)
|
|
request = r.requestProfileLocked(now, r.windowRate)
|
|
r.windowRate = 0
|
|
}
|
|
r.access.Unlock()
|
|
if request != nil {
|
|
r.startProfile(request)
|
|
}
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) beginWakeWindowLocked(now time.Time) {
|
|
r.awake = true
|
|
r.wakeAt = now
|
|
r.wakeUsage = readSystemUsage()
|
|
r.wakeDNSQueries = r.dnsQueries.Load()
|
|
r.wakeConnections = r.connectionsOpened.Load()
|
|
}
|
|
|
|
func (r *Recorder) endWakeWindowLocked(now time.Time) *wakeWindow {
|
|
r.awake = false
|
|
usage := readSystemUsage()
|
|
window := &wakeWindow{
|
|
AwakeMS: int64(now.Sub(r.wakeAt) / time.Millisecond),
|
|
DNSQueries: r.dnsQueries.Load() - r.wakeDNSQueries,
|
|
ConnectionsOpened: r.connectionsOpened.Load() - r.wakeConnections,
|
|
}
|
|
if usage.valid && r.wakeUsage.valid {
|
|
window.CPUUserMS = (usage.userTime - r.wakeUsage.userTime) / int64(time.Millisecond)
|
|
window.CPUSystemMS = (usage.systemTime - r.wakeUsage.systemTime) / int64(time.Millisecond)
|
|
window.EnergyNanojoules = usage.energyNanojoules - r.wakeUsage.energyNanojoules
|
|
window.PackageIdleWakeups = usage.packageIdleWakeups - r.wakeUsage.packageIdleWakeups
|
|
window.InterruptWakeups = usage.interruptWakeups - r.wakeUsage.interruptWakeups
|
|
}
|
|
r.windowRate = profileRate(window.EnergyNanojoules, window.CPUUserMS+window.CPUSystemMS, now.Sub(r.wakeAt))
|
|
return window
|
|
}
|
|
|
|
func (r *Recorder) recordPlatformEvent(eventType string) {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.events = append(r.events, r.newEvent(eventType, now))
|
|
r.access.Unlock()
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) UpdateNetworkType(networkType string) {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if r.closed || r.networkType == networkType {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.networkType = networkType
|
|
event := r.newEvent(eventTypeNetwork, now)
|
|
event.NetworkType = networkType
|
|
r.events = append(r.events, event)
|
|
r.access.Unlock()
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) UpdateNetworkPath(description string) {
|
|
r.networkPathUpdates.Add(1)
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if r.closed || r.networkPath == description {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.networkPath = description
|
|
event := r.newEvent(eventTypePath, now)
|
|
event.NetworkPath = description
|
|
r.events = append(r.events, event)
|
|
r.access.Unlock()
|
|
r.notifyWorker()
|
|
}
|
|
|
|
func (r *Recorder) newEvent(eventType string, now time.Time) eventRecord {
|
|
return eventRecord{
|
|
Type: eventType,
|
|
At: now.UTC().Format(time.RFC3339),
|
|
MonoMS: int64(now.Sub(r.baseTime) / time.Millisecond),
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) notifyWorker() {
|
|
select {
|
|
case r.notify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) worker() {
|
|
defer close(r.workerDone)
|
|
timer := time.NewTimer(r.fallbackInterval)
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-r.done:
|
|
return
|
|
case <-r.notify:
|
|
case <-timer.C:
|
|
timer.Reset(r.fallbackInterval)
|
|
}
|
|
r.process()
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) process() {
|
|
now := time.Now()
|
|
r.access.Lock()
|
|
if !r.started || r.closed {
|
|
r.access.Unlock()
|
|
return
|
|
}
|
|
r.consumeBreakLocked()
|
|
var request *profileRequest
|
|
nowNano := int64(now.Sub(r.baseTime))
|
|
if nowNano-r.lastSampleAt.Load() >= r.sampleNano {
|
|
r.lastSampleAt.Store(nowNano)
|
|
request = r.sampleLocked(now)
|
|
}
|
|
if now.Sub(r.lastFlushAt) >= r.flushInterval || len(r.rows) >= rowCapacity || len(r.events) >= eventCapacity {
|
|
r.flushLocked(now)
|
|
}
|
|
r.access.Unlock()
|
|
if request != nil {
|
|
r.startProfile(request)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) consumeBreakLocked() {
|
|
record := r.pendingBreak.Swap(nil)
|
|
if record == nil {
|
|
return
|
|
}
|
|
event := r.newEvent(eventTypeBreak, record.at)
|
|
event.IdleMS = record.idleMS
|
|
event.WallIdleMS = record.wallIdleMS
|
|
event.AfterWake = record.afterWake
|
|
event.SinceWakeMS = record.sinceWakeMS
|
|
event.Direction = record.direction.String()
|
|
event.Size = record.size
|
|
event.NetworkType = r.networkType
|
|
event.By = record.by
|
|
r.events = append(r.events, event)
|
|
}
|
|
|
|
func (r *Recorder) resetPreviousLocked(now time.Time) {
|
|
metrics.Read(r.metricsSamples)
|
|
r.previous = previousSample{
|
|
at: now,
|
|
usage: readSystemUsage(),
|
|
gcSeconds: r.metricsSamples[0].Value.Float64(),
|
|
gcCycles: r.metricsSamples[2].Value.Uint64(),
|
|
interfaces: readInterfaceCounters(),
|
|
dnsQueries: r.dnsQueries.Load(),
|
|
connectionsOpened: r.connectionsOpened.Load(),
|
|
networkPathUpdates: r.networkPathUpdates.Load(),
|
|
traffic: r.snapshotTrafficLocked(),
|
|
}
|
|
r.previous.absoluteTime, r.previous.continuousTime = readClocks()
|
|
}
|
|
|
|
func (r *Recorder) sampleLocked(now time.Time) *profileRequest {
|
|
previous := r.previous
|
|
r.resetPreviousLocked(now)
|
|
current := &r.previous
|
|
row := timelineRow{
|
|
From: previous.at.UTC().Format(time.RFC3339),
|
|
To: now.UTC().Format(time.RFC3339),
|
|
MonoMS: int64(now.Sub(r.baseTime) / time.Millisecond),
|
|
AwakeMS: int64(now.Sub(previous.at) / time.Millisecond),
|
|
CPUGCMS: int64((current.gcSeconds - previous.gcSeconds) * 1000),
|
|
Goroutines: r.metricsSamples[1].Value.Uint64(),
|
|
GCCycles: current.gcCycles - previous.gcCycles,
|
|
GoMemoryBytes: r.metricsSamples[3].Value.Uint64(),
|
|
GoHeapLiveBytes: r.metricsSamples[4].Value.Uint64(),
|
|
DNSQueries: current.dnsQueries - previous.dnsQueries,
|
|
DNSDomains: r.collectDNSDomains(),
|
|
ConnectionsOpened: current.connectionsOpened - previous.connectionsOpened,
|
|
Traffic: trafficDeltas(current.traffic, previous.traffic),
|
|
NetworkType: r.networkType,
|
|
NetworkPathUpdates: current.networkPathUpdates - previous.networkPathUpdates,
|
|
}
|
|
if memory.TotalAvailable() {
|
|
row.MemoryBytes = memory.Total()
|
|
}
|
|
if current.usage.valid && previous.usage.valid {
|
|
row.CPUUserMS = (current.usage.userTime - previous.usage.userTime) / int64(time.Millisecond)
|
|
row.CPUSystemMS = (current.usage.systemTime - previous.usage.systemTime) / int64(time.Millisecond)
|
|
row.CPUPerformanceMS = (current.usage.performanceUserTime - previous.usage.performanceUserTime +
|
|
current.usage.performanceSystemTime - previous.usage.performanceSystemTime) / int64(time.Millisecond)
|
|
row.PackageIdleWakeups = current.usage.packageIdleWakeups - previous.usage.packageIdleWakeups
|
|
row.InterruptWakeups = current.usage.interruptWakeups - previous.usage.interruptWakeups
|
|
row.EnergyNanojoules = current.usage.energyNanojoules - previous.usage.energyNanojoules
|
|
row.PerformanceEnergyNanojoules = current.usage.performanceEnergyNanojoules - previous.usage.performanceEnergyNanojoules
|
|
row.DiskBytesWritten = current.usage.diskBytesWritten - previous.usage.diskBytesWritten
|
|
qos := qosBreakdown{
|
|
DefaultMS: (current.usage.qosDefaultTime - previous.usage.qosDefaultTime) / int64(time.Millisecond),
|
|
MaintenanceMS: (current.usage.qosMaintenanceTime - previous.usage.qosMaintenanceTime) / int64(time.Millisecond),
|
|
BackgroundMS: (current.usage.qosBackgroundTime - previous.usage.qosBackgroundTime) / int64(time.Millisecond),
|
|
UtilityMS: (current.usage.qosUtilityTime - previous.usage.qosUtilityTime) / int64(time.Millisecond),
|
|
LegacyMS: (current.usage.qosLegacyTime - previous.usage.qosLegacyTime) / int64(time.Millisecond),
|
|
UserInitiatedMS: (current.usage.qosUserInitiatedTime - previous.usage.qosUserInitiatedTime) / int64(time.Millisecond),
|
|
UserInteractiveMS: (current.usage.qosUserInteractiveTime - previous.usage.qosUserInteractiveTime) / int64(time.Millisecond),
|
|
}
|
|
if qos != (qosBreakdown{}) {
|
|
row.QoSMS = &qos
|
|
}
|
|
}
|
|
if current.absoluteTime != 0 && previous.absoluteTime != 0 && current.absoluteTime >= previous.absoluteTime {
|
|
sleptNano := (current.continuousTime - previous.continuousTime) - (current.absoluteTime - previous.absoluteTime)
|
|
wallNano := now.UnixNano() - previous.at.UnixNano()
|
|
if sleptNano > wallNano {
|
|
sleptNano = wallNano
|
|
}
|
|
if sleptNano > 0 {
|
|
row.SleptMS = sleptNano / int64(time.Millisecond)
|
|
}
|
|
}
|
|
if len(current.interfaces) > 0 && len(previous.interfaces) > 0 {
|
|
interfacePackets := make(map[string]uint64)
|
|
interfaceBytes := make(map[string]uint64)
|
|
for name, counters := range current.interfaces {
|
|
previousCounters, found := previous.interfaces[name]
|
|
if !found {
|
|
continue
|
|
}
|
|
packets := counters.inPackets - previousCounters.inPackets + counters.outPackets - previousCounters.outPackets
|
|
if packets > 0 {
|
|
interfacePackets[name] = packets
|
|
}
|
|
bytesDelta := counters.inBytes - previousCounters.inBytes + counters.outBytes - previousCounters.outBytes
|
|
if bytesDelta > 0 {
|
|
interfaceBytes[name] = bytesDelta
|
|
}
|
|
}
|
|
if len(interfacePackets) > 0 {
|
|
row.InterfacePackets = interfacePackets
|
|
}
|
|
if len(interfaceBytes) > 0 {
|
|
row.InterfaceBytes = interfaceBytes
|
|
}
|
|
}
|
|
r.updateDeviceStateLocked(now)
|
|
r.rows = append(r.rows, row)
|
|
return r.requestProfileLocked(now, profileRate(row.EnergyNanojoules, row.CPUUserMS+row.CPUSystemMS, now.Sub(previous.at)))
|
|
}
|
|
|
|
func (r *Recorder) collectDNSDomains() map[string]uint64 {
|
|
r.dnsAccess.Lock()
|
|
defer r.dnsAccess.Unlock()
|
|
if len(r.dnsDomains) == 0 {
|
|
return nil
|
|
}
|
|
type domainCount struct {
|
|
domain string
|
|
count uint64
|
|
}
|
|
counts := make([]domainCount, 0, len(r.dnsDomains))
|
|
for domain, count := range r.dnsDomains {
|
|
counts = append(counts, domainCount{domain, count})
|
|
}
|
|
clear(r.dnsDomains)
|
|
slices.SortFunc(counts, func(a domainCount, b domainCount) int {
|
|
return cmp.Compare(b.count, a.count)
|
|
})
|
|
if len(counts) > maxRowDNSDomains {
|
|
counts = counts[:maxRowDNSDomains]
|
|
}
|
|
result := make(map[string]uint64, len(counts))
|
|
for _, entry := range counts {
|
|
result[entry.domain] = entry.count
|
|
}
|
|
return result
|
|
}
|
|
|
|
func trafficDeltas(current map[trafficKey]trafficBytes, previous map[trafficKey]trafficBytes) trafficBreakdown {
|
|
var result trafficBreakdown
|
|
for key, currentBytes := range current {
|
|
previousBytes := previous[key]
|
|
delta := trafficDelta{
|
|
In: currentBytes.inBytes - previousBytes.inBytes,
|
|
Out: currentBytes.outBytes - previousBytes.outBytes,
|
|
Dials: currentBytes.dials - previousBytes.dials,
|
|
}
|
|
if delta == (trafficDelta{}) {
|
|
continue
|
|
}
|
|
if result == nil {
|
|
result = make(trafficBreakdown)
|
|
}
|
|
byTag := result[key.kind]
|
|
if byTag == nil {
|
|
byTag = make(map[string]*trafficDelta)
|
|
result[key.kind] = byTag
|
|
}
|
|
byTag[key.tag] = &delta
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (r *Recorder) updateDeviceStateLocked(now time.Time) {
|
|
state, loaded := readDeviceState()
|
|
if !loaded {
|
|
return
|
|
}
|
|
if r.deviceStateValid && r.lastDeviceState == state {
|
|
return
|
|
}
|
|
r.lastDeviceState = state
|
|
r.deviceStateValid = true
|
|
event := r.newEvent(eventTypeDevice, now)
|
|
event.Device = &state
|
|
r.events = append(r.events, event)
|
|
}
|
|
|
|
func profileRate(energyNanojoules uint64, cpuMS int64, duration time.Duration) float64 {
|
|
if duration <= 0 {
|
|
return 0
|
|
}
|
|
work := float64(energyNanojoules)
|
|
if work == 0 {
|
|
work = float64(cpuMS)
|
|
}
|
|
return work / duration.Seconds()
|
|
}
|
|
|
|
func (r *Recorder) requestProfileLocked(now time.Time, rate float64) *profileRequest {
|
|
if r.closed || rate == 0 || r.profileFile != nil {
|
|
return nil
|
|
}
|
|
lowestIndex := -1
|
|
if len(r.profiles) >= maxProfiles {
|
|
lowestIndex = 0
|
|
for index := 1; index < len(r.profiles); index++ {
|
|
if r.profiles[index].rate < r.profiles[lowestIndex].rate {
|
|
lowestIndex = index
|
|
}
|
|
}
|
|
if rate <= r.profiles[lowestIndex].rate {
|
|
return nil
|
|
}
|
|
}
|
|
if !r.lastProfileAt.IsZero() && now.Sub(r.lastProfileAt) < profileMinInterval {
|
|
return nil
|
|
}
|
|
request := &profileRequest{
|
|
writePath: filepath.Join(r.draftPath, "cpu-"+now.UTC().Format("150405")+".pb"),
|
|
}
|
|
if lowestIndex >= 0 {
|
|
request.removePath = r.profiles[lowestIndex].path
|
|
r.profiles = slices.Delete(r.profiles, lowestIndex, lowestIndex+1)
|
|
}
|
|
r.profiles = append(r.profiles, profileEntry{rate: rate, path: request.writePath})
|
|
r.lastProfileAt = now
|
|
return request
|
|
}
|
|
|
|
func (r *Recorder) startProfile(request *profileRequest) {
|
|
if request.removePath != "" {
|
|
os.Remove(request.removePath)
|
|
}
|
|
file, err := os.OpenFile(request.writePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666)
|
|
if err != nil {
|
|
return
|
|
}
|
|
r.chown(request.writePath)
|
|
r.access.Lock()
|
|
defer r.access.Unlock()
|
|
if r.closed || r.profileFile != nil {
|
|
file.Close()
|
|
os.Remove(request.writePath)
|
|
return
|
|
}
|
|
err = pprof.StartCPUProfile(file)
|
|
if err != nil {
|
|
file.Close()
|
|
os.Remove(request.writePath)
|
|
return
|
|
}
|
|
r.profileFile = file
|
|
r.profileTimer = time.AfterFunc(profileMaxDuration, r.stopProfile)
|
|
}
|
|
|
|
func (r *Recorder) stopProfile() {
|
|
r.access.Lock()
|
|
defer r.access.Unlock()
|
|
r.stopProfileLocked()
|
|
}
|
|
|
|
func (r *Recorder) stopProfileLocked() {
|
|
if r.profileFile == nil {
|
|
return
|
|
}
|
|
r.profileTimer.Stop()
|
|
pprof.StopCPUProfile()
|
|
r.profileFile.Close()
|
|
r.profileFile = nil
|
|
}
|
|
|
|
func (r *Recorder) chown(path string) {
|
|
if r.ownerCallback != nil {
|
|
r.ownerCallback(path)
|
|
}
|
|
}
|
|
|
|
func (r *Recorder) flushLocked(now time.Time) {
|
|
err := appendRecords(r, filepath.Join(r.draftPath, timelineFileName), r.rows)
|
|
if err == nil {
|
|
r.rows = r.rows[:0]
|
|
} else {
|
|
r.logger.Error(E.Cause(err, "power report: write timeline"))
|
|
if len(r.rows) >= rowCapacity {
|
|
r.rows = r.rows[len(r.rows)-rowCapacity/2:]
|
|
}
|
|
}
|
|
err = appendRecords(r, filepath.Join(r.draftPath, eventsFileName), r.events)
|
|
if err == nil {
|
|
r.events = r.events[:0]
|
|
} else {
|
|
r.logger.Error(E.Cause(err, "power report: write events"))
|
|
if len(r.events) >= eventCapacity {
|
|
r.events = r.events[len(r.events)-eventCapacity/2:]
|
|
}
|
|
}
|
|
r.lastFlushAt = now
|
|
}
|
|
|
|
func appendRecords[T any](r *Recorder, path string, records []T) error {
|
|
if len(records) == 0 {
|
|
return nil
|
|
}
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
r.chown(path)
|
|
encoder := json.NewEncoder(file)
|
|
for _, record := range records {
|
|
err = encoder.Encode(record)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Recorder) writeProfiles() {
|
|
if r.profileCallback != nil {
|
|
r.profileCallback(r.draftPath)
|
|
return
|
|
}
|
|
profilePath := filepath.Join(r.draftPath, goroutineProfileFileName)
|
|
file, err := os.OpenFile(profilePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o666)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer file.Close()
|
|
r.chown(profilePath)
|
|
pprof.Lookup("goroutine").WriteTo(file, 0)
|
|
}
|