del: 将go版本的内容删除,只保留处理日志功能
This commit is contained in:
166
go/cmd/internal/latencylog/logger.go
Normal file
166
go/cmd/internal/latencylog/logger.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
const (
|
||||
NodeRolePeer = "peer" //客户端节点
|
||||
NodeRoleServer = "server" //云端转发节点
|
||||
)
|
||||
|
||||
// 记录的消息事件的类型常量。
|
||||
const (
|
||||
EventAAppPrepBegin = "A_APP_PREP_BEGIN" // A 端应用开始准备这条消息
|
||||
EventATXSched = "A_TX_SCHED" // A 端进入 Linux qdisc 之前
|
||||
EventATXSoftware = "A_TX_SOFTWARE" // A 端即将交给网卡驱动
|
||||
EventATXHardware = "A_TX_HARDWARE" // A 端网卡真正发出到物理介质
|
||||
EventBRXHardware = "B_RX_HARDWARE" // B 端网卡真正从物理介质收到
|
||||
EventBRXSoftware = "B_RX_SOFTWARE" // B 端驱动把数据交给 Linux 接收栈
|
||||
EventBAppRecv = "B_APP_RECV" // B 端应用真正读到完整消息
|
||||
EventBPersistBegin = "B_PERSIST_BEGIN" // B 端开始写盘
|
||||
EventBPersistEnd = "B_PERSIST_END" // B 端写盘完成
|
||||
|
||||
EventSendHandoffBegin = "send_handoff_begin" // 调试事件:应用把消息交给传输层开始
|
||||
EventSendHandoffEnd = "send_handoff_end" // 调试事件:应用把消息交给传输层结束
|
||||
)
|
||||
|
||||
// Event 是一条时延时间戳日志记录。
|
||||
type Event struct {
|
||||
TsUnixNano int64 `json:"ts_unix_nano"`
|
||||
NodeRole string `json:"node_role"`
|
||||
NodeID string `json:"node_id"`
|
||||
Event string `json:"event"`
|
||||
MessageType protocol.MessageType `json:"message_type"`
|
||||
MessageID uint64 `json:"message_id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
BodySize int `json:"body_size"`
|
||||
}
|
||||
|
||||
// Logger 负责接收事件并将其写入外部介质。
|
||||
type Logger interface {
|
||||
LogEvent(Event) error
|
||||
}
|
||||
|
||||
// NoopLogger 是默认的空实现。
|
||||
type NoopLogger struct{}
|
||||
|
||||
// LogEvent 对空日志实现始终返回 nil。
|
||||
func (NoopLogger) LogEvent(Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSONLLogger 以 JSONL 形式追加写日志文件。
|
||||
type JSONLLogger struct {
|
||||
mu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
file *os.File
|
||||
}
|
||||
|
||||
// NewJSONLLogger 创建一个线程安全的 JSONL 文件日志器。
|
||||
func NewJSONLLogger(path string) (*JSONLLogger, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &JSONLLogger{file: file}, nil
|
||||
}
|
||||
|
||||
// LogEvent 以单行 JSON 的形式追加一条事件。
|
||||
func (l *JSONLLogger) LogEvent(event Event) error {
|
||||
line, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if _, err := l.file.Write(append(line, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭底层文件;重复调用是安全的。
|
||||
func (l *JSONLLogger) Close() error {
|
||||
l.closeOnce.Do(func() {
|
||||
l.closeErr = l.file.Close()
|
||||
})
|
||||
|
||||
return l.closeErr
|
||||
}
|
||||
|
||||
// IsBusinessMessage 判断消息是否属于要参与 A-C-B 时延分析的业务消息。
|
||||
func IsBusinessMessage(msg protocol.Message) bool {
|
||||
switch msg.Type {
|
||||
case protocol.MessageTypeText, protocol.MessageTypeFile:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// NewMessageEvent 用当前 UTC 时间为一条业务消息构造事件。
|
||||
func NewMessageEvent(nodeRole, nodeID, eventName string, msg protocol.Message) Event {
|
||||
return NewMessageEventAt(time.Now().UTC().UnixNano(), nodeRole, nodeID, eventName, msg)
|
||||
}
|
||||
|
||||
// NewMessageEventAt 用指定的 UnixNano 时间为一条业务消息构造事件。
|
||||
func NewMessageEventAt(tsUnixNano int64, nodeRole, nodeID, eventName string, msg protocol.Message) Event {
|
||||
return Event{
|
||||
TsUnixNano: tsUnixNano,
|
||||
NodeRole: nodeRole,
|
||||
NodeID: nodeID,
|
||||
Event: eventName,
|
||||
MessageType: msg.Type,
|
||||
MessageID: msg.ID,
|
||||
From: msg.From,
|
||||
To: msg.To,
|
||||
FileName: msg.FileName,
|
||||
BodySize: len(msg.Body),
|
||||
}
|
||||
}
|
||||
|
||||
// LogBestEffort 写一条事件,失败时静默忽略,避免打断主收发流程。
|
||||
func LogBestEffort(logger Logger, event Event) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = logger.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogMessageEvent 为业务消息构造并写入一条事件。
|
||||
func LogMessageEvent(logger Logger, nodeRole, nodeID, eventName string, msg protocol.Message) {
|
||||
if !IsBusinessMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
LogBestEffort(logger, NewMessageEvent(nodeRole, nodeID, eventName, msg))
|
||||
}
|
||||
|
||||
// LogMessageEventAt 为业务消息写入一条指定时间戳的事件。
|
||||
func LogMessageEventAt(logger Logger, nodeRole, nodeID, eventName string, tsUnixNano int64, msg protocol.Message) {
|
||||
if !IsBusinessMessage(msg) {
|
||||
return
|
||||
}
|
||||
|
||||
LogBestEffort(logger, NewMessageEventAt(tsUnixNano, nodeRole, nodeID, eventName, msg))
|
||||
}
|
||||
131
go/cmd/internal/latencylog/logger_test.go
Normal file
131
go/cmd/internal/latencylog/logger_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestJSONLLoggerWritesOneEventPerLine(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "latency.jsonl")
|
||||
|
||||
logger, err := NewJSONLLogger(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
})
|
||||
|
||||
event := Event{
|
||||
TsUnixNano: 123,
|
||||
NodeRole: NodeRolePeer,
|
||||
NodeID: "peer-a",
|
||||
Event: EventAAppPrepBegin,
|
||||
MessageType: protocol.MessageTypeText,
|
||||
MessageID: 1,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
BodySize: 5,
|
||||
}
|
||||
if err := logger.LogEvent(event); err != nil {
|
||||
t.Fatalf("LogEvent() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("expected one JSONL line, got none")
|
||||
}
|
||||
|
||||
var got Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &got); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if got != event {
|
||||
t.Fatalf("event mismatch: got %+v want %+v", got, event)
|
||||
}
|
||||
if scanner.Scan() {
|
||||
t.Fatal("expected exactly one JSONL line")
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err() = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONLLoggerHandlesConcurrentWrites(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "latency.jsonl")
|
||||
|
||||
logger, err := NewJSONLLogger(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
})
|
||||
|
||||
const total = 32
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < total; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
err := logger.LogEvent(Event{
|
||||
TsUnixNano: int64(i + 1),
|
||||
NodeRole: NodeRoleServer,
|
||||
NodeID: protocol.ServerPeerID,
|
||||
Event: EventBAppRecv,
|
||||
MessageType: protocol.MessageTypeFile,
|
||||
MessageID: uint64(i + 1),
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
FileName: "payload.bin",
|
||||
BodySize: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("LogEvent() error = %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var count int
|
||||
seen := make(map[uint64]bool, total)
|
||||
for scanner.Scan() {
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
count++
|
||||
seen[event.MessageID] = true
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
t.Fatalf("scanner.Err() = %v", err)
|
||||
}
|
||||
if count != total {
|
||||
t.Fatalf("line count = %d, want %d", count, total)
|
||||
}
|
||||
if len(seen) != total {
|
||||
t.Fatalf("unique message count = %d, want %d", len(seen), total)
|
||||
}
|
||||
}
|
||||
457
go/cmd/internal/latencylog/summary.go
Normal file
457
go/cmd/internal/latencylog/summary.go
Normal file
@@ -0,0 +1,457 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
// Summary 是针对单条消息的时延的规则列表。
|
||||
var requiredTimestampNames = []string{
|
||||
EventAAppPrepBegin, // A 端应用开始准备这条消息
|
||||
EventATXSched, // A 端进入 Linux qdisc 之前
|
||||
EventATXSoftware, // A 端即将交给网卡驱动
|
||||
EventBRXSoftware, // B 端网卡驱动把数据交给 Linux 接收栈
|
||||
EventBAppRecv, // B 端应用真正读到完整消息
|
||||
EventBPersistEnd, // B 端写盘完成
|
||||
}
|
||||
|
||||
// Summary 是针对单条消息的时延整理结果。
|
||||
type Summary struct {
|
||||
MessageType protocol.MessageType `json:"message_type"` //消息类型
|
||||
MessageID uint64 `json:"message_id"` //消息ID
|
||||
From string `json:"from"` //发送方
|
||||
To string `json:"to"` //接收方
|
||||
FileName string `json:"file_name,omitempty"` //文件名(仅文件消息)
|
||||
BodySize int `json:"body_size"` //消息体大小(字节数)
|
||||
Timestamps map[string]int64 `json:"timestamps"` //事件时间戳,key 是事件名称,value 是 UnixNano 时间戳
|
||||
|
||||
AProcessingLatencyNS *int64 `json:"a_processing_latency_ns,omitempty"` // A 处理时延:A_TX_SCHED - A_APP_PREP_BEGIN
|
||||
AQueueLatencyNS *int64 `json:"a_queue_latency_ns,omitempty"` // A 排队时延:A_TX_SOFTWARE - A_TX_SCHED
|
||||
ABTransportPropagationNS *int64 `json:"a_b_transport_propagation_ns,omitempty"` // A-B 传输+传播时延近似:B_APP_RECV - A_TX_SOFTWARE
|
||||
BKernelReceivePathLatencyNS *int64 `json:"b_kernel_receive_path_latency_ns,omitempty"` // B 内核接收路径近似:B_APP_RECV - B_RX_SOFTWARE
|
||||
BProcessingLatencyNS *int64 `json:"b_processing_latency_ns,omitempty"` // B 处理时延:B_PERSIST_END - B_APP_RECV
|
||||
EndToEndLatencyNS *int64 `json:"end_to_end_latency_ns,omitempty"` // 端到端时延:B_PERSIST_END - A_APP_PREP_BEGIN
|
||||
AProcessingBitrateBPS *float64 `json:"a_processing_bitrate_bps,omitempty"` // A 处理阶段近似比特率:(BodySize * 8) / A 处理时延(秒)
|
||||
ABTransportPropagationBitrateBPS *float64 `json:"a_b_transport_propagation_bitrate_bps,omitempty"` // A-B 传输+传播阶段近似比特率:(BodySize * 8) / A-B 传输+传播时延(秒)
|
||||
EndToEndBitrateBPS *float64 `json:"end_to_end_bitrate_bps,omitempty"` // 端到端近似比特率:(BodySize * 8) / 端到端时延(秒)
|
||||
ApproxRTTNS *int64 `json:"approx_rtt_ns,omitempty"` // 近似 RTT:首条反向应答的 B_APP_RECV - 当前请求的 A_TX_SOFTWARE
|
||||
MissingTimestamps []string `json:"missing_timestamps,omitempty"` // 缺失的时间戳列表,包含 requiredTimestampNames 中但在原始事件中没有的事件名称
|
||||
}
|
||||
|
||||
// LoadEventsFromFiles 从JSONL 原始日志文件中加载事件。
|
||||
type messageKey struct {
|
||||
MessageType protocol.MessageType //消息类型
|
||||
MessageID uint64 //消息ID
|
||||
From string //发送方
|
||||
To string //接收方
|
||||
}
|
||||
|
||||
// LoadEventsFromFiles 从多个 JSONL 原始日志文件中加载事件。
|
||||
func LoadEventsFromFiles(paths []string) ([]Event, error) {
|
||||
var events []Event
|
||||
for _, path := range paths {
|
||||
fileEvents, err := LoadEventsFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, fileEvents...)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// LoadEventsFromFilesWithSharedMaxOffset 从多个 JSONL 原始日志文件中加载事件,
|
||||
// 并按每个输入文件的最大 message_id 计算共享截断点。
|
||||
func LoadEventsFromFilesWithSharedMaxOffset(paths []string, sharedMaxOffset uint64) ([]Event, *uint64, error) {
|
||||
eventsByFile := make([][]Event, 0, len(paths))
|
||||
var minMaxMessageID uint64
|
||||
hasSharedMax := false
|
||||
|
||||
for _, path := range paths {
|
||||
fileEvents, err := LoadEventsFromFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
eventsByFile = append(eventsByFile, fileEvents)
|
||||
|
||||
fileMaxMessageID, ok := maxBusinessMessageID(fileEvents)
|
||||
if !ok {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if !hasSharedMax || fileMaxMessageID < minMaxMessageID {
|
||||
minMaxMessageID = fileMaxMessageID
|
||||
hasSharedMax = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSharedMax {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
cutoff, ok := subtractUint64(minMaxMessageID, sharedMaxOffset)
|
||||
if !ok {
|
||||
return []Event{}, nil, nil
|
||||
}
|
||||
|
||||
var events []Event
|
||||
for _, fileEvents := range eventsByFile {
|
||||
events = append(events, filterEventsByMaxMessageID(fileEvents, cutoff)...)
|
||||
}
|
||||
|
||||
return events, &cutoff, nil
|
||||
}
|
||||
|
||||
// LoadEventsFromFile 从单个 JSONL 原始日志文件中加载事件。
|
||||
func LoadEventsFromFile(path string) ([]Event, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("latencylog: open raw log %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var events []Event
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
if len(scanner.Bytes()) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var event Event
|
||||
if err := json.Unmarshal(scanner.Bytes(), &event); err != nil { //解析 JSONL 行失败,返回错误
|
||||
return nil, fmt.Errorf("latencylog: decode event from %s: %w", path, err)
|
||||
}
|
||||
events = append(events, event)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("latencylog: scan raw log %s: %w", path, err)
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// SummarizeEvents 将原始事件整理成按消息分组的时延结果。
|
||||
func SummarizeEvents(events []Event) []Summary {
|
||||
grouped := make(map[messageKey]*Summary)
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
|
||||
key := messageKey{
|
||||
MessageType: event.MessageType,
|
||||
MessageID: event.MessageID,
|
||||
From: event.From,
|
||||
To: event.To,
|
||||
}
|
||||
|
||||
summary, ok := grouped[key]
|
||||
if !ok {
|
||||
summary = &Summary{
|
||||
MessageType: event.MessageType,
|
||||
MessageID: event.MessageID,
|
||||
From: event.From,
|
||||
To: event.To,
|
||||
FileName: event.FileName,
|
||||
BodySize: event.BodySize,
|
||||
Timestamps: make(map[string]int64),
|
||||
}
|
||||
grouped[key] = summary
|
||||
}
|
||||
|
||||
if summary.FileName == "" {
|
||||
summary.FileName = event.FileName
|
||||
}
|
||||
if event.BodySize > 0 {
|
||||
summary.BodySize = event.BodySize
|
||||
}
|
||||
|
||||
if existing, exists := summary.Timestamps[event.Event]; !exists || event.TsUnixNano < existing {
|
||||
summary.Timestamps[event.Event] = event.TsUnixNano
|
||||
}
|
||||
}
|
||||
|
||||
summaryPointers := make([]*Summary, 0, len(grouped))
|
||||
for _, summary := range grouped {
|
||||
completeSummary(summary) //补全时延指标和缺失时间戳信息
|
||||
summaryPointers = append(summaryPointers, summary)
|
||||
}
|
||||
assignApproxRTTs(summaryPointers)
|
||||
|
||||
summaries := make([]Summary, 0, len(summaryPointers))
|
||||
for _, summary := range summaryPointers {
|
||||
summaries = append(summaries, *summary)
|
||||
}
|
||||
//对整理结果进行排序,先按发送方、再按接收方、再按消息 ID、最后按消息类型排序,保证输出的稳定性和可读性。
|
||||
sort.Slice(summaries, func(i, j int) bool {
|
||||
if summaries[i].From != summaries[j].From {
|
||||
return summaries[i].From < summaries[j].From
|
||||
}
|
||||
if summaries[i].To != summaries[j].To {
|
||||
return summaries[i].To < summaries[j].To
|
||||
}
|
||||
if summaries[i].MessageID != summaries[j].MessageID {
|
||||
return summaries[i].MessageID < summaries[j].MessageID
|
||||
}
|
||||
return summaries[i].MessageType < summaries[j].MessageType
|
||||
})
|
||||
|
||||
return summaries
|
||||
}
|
||||
|
||||
// WriteSummariesJSONL 将整理结果写成 JSONL 汇总文件。
|
||||
func WriteSummariesJSONL(path string, summaries []Summary) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("latencylog: create summary dir for %s: %w", path, err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: open summary file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
for _, summary := range summaries { //将每条整理结果编码成 JSONL 行并写入文件
|
||||
line, err := json.Marshal(summary)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: encode summary for message %d: %w", summary.MessageID, err)
|
||||
}
|
||||
if _, err := writer.Write(append(line, '\n')); err != nil {
|
||||
return fmt.Errorf("latencylog: write summary file %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Flush(); err != nil { //将缓冲区内容写入文件
|
||||
return fmt.Errorf("latencylog: flush summary file %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// completeSummary 根据事件时间戳计算时延指标,并找出缺失的时间戳。
|
||||
func completeSummary(summary *Summary) {
|
||||
summary.MissingTimestamps = missingTimestampNames(summary.Timestamps)
|
||||
|
||||
if value := subtractIfPresent(summary.Timestamps, EventATXSched, EventAAppPrepBegin); value != nil {
|
||||
summary.AProcessingLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventATXSoftware, EventATXSched); value != nil {
|
||||
summary.AQueueLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventATXSoftware); value != nil {
|
||||
summary.ABTransportPropagationNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBAppRecv, EventBRXSoftware); value != nil {
|
||||
summary.BKernelReceivePathLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventBAppRecv); value != nil {
|
||||
summary.BProcessingLatencyNS = value
|
||||
}
|
||||
if value := subtractIfPresent(summary.Timestamps, EventBPersistEnd, EventAAppPrepBegin); value != nil {
|
||||
summary.EndToEndLatencyNS = value
|
||||
}
|
||||
|
||||
summary.AProcessingBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.AProcessingLatencyNS)
|
||||
summary.ABTransportPropagationBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.ABTransportPropagationNS)
|
||||
summary.EndToEndBitrateBPS = calculateBitrateBPS(summary.BodySize, summary.EndToEndLatencyNS)
|
||||
}
|
||||
|
||||
type routeKey struct {
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func assignApproxRTTs(summaries []*Summary) {
|
||||
grouped := make(map[routeKey][]*Summary)
|
||||
for _, summary := range summaries {
|
||||
grouped[routeKey{From: summary.From, To: summary.To}] = append(grouped[routeKey{From: summary.From, To: summary.To}], summary)
|
||||
}
|
||||
|
||||
for key, requests := range grouped {
|
||||
replies := grouped[routeKey{From: key.To, To: key.From}]
|
||||
if len(replies) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
assignApproxRTTsForRoute(
|
||||
sortSummariesByTimestamp(requests, EventBAppRecv),
|
||||
sortSummariesByTimestamp(replies, EventATXSoftware),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func assignApproxRTTsForRoute(requests, replies []*Summary) {
|
||||
replyIndex := 0
|
||||
for _, request := range requests {
|
||||
requestReceivedAtResponder, ok := request.Timestamps[EventBAppRecv]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for replyIndex < len(replies) {
|
||||
reply := replies[replyIndex]
|
||||
replySentAtResponder, ok := reply.Timestamps[EventATXSoftware]
|
||||
if !ok {
|
||||
replyIndex++
|
||||
continue
|
||||
}
|
||||
if replySentAtResponder < requestReceivedAtResponder {
|
||||
replyIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
if value := subtractSummaryTimestamps(reply, EventBAppRecv, request, EventATXSoftware); value != nil {
|
||||
request.ApproxRTTNS = value
|
||||
}
|
||||
replyIndex++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sortSummariesByTimestamp(summaries []*Summary, eventName string) []*Summary {
|
||||
sorted := append([]*Summary(nil), summaries...)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
leftTS, leftOK := sorted[i].Timestamps[eventName]
|
||||
rightTS, rightOK := sorted[j].Timestamps[eventName]
|
||||
switch {
|
||||
case leftOK && rightOK:
|
||||
if leftTS != rightTS {
|
||||
return leftTS < rightTS
|
||||
}
|
||||
case leftOK:
|
||||
return true
|
||||
case rightOK:
|
||||
return false
|
||||
}
|
||||
|
||||
if sorted[i].MessageID != sorted[j].MessageID {
|
||||
return sorted[i].MessageID < sorted[j].MessageID
|
||||
}
|
||||
if sorted[i].From != sorted[j].From {
|
||||
return sorted[i].From < sorted[j].From
|
||||
}
|
||||
if sorted[i].To != sorted[j].To {
|
||||
return sorted[i].To < sorted[j].To
|
||||
}
|
||||
|
||||
return sorted[i].MessageType < sorted[j].MessageType
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
// 返回 requiredTimestampNames 中哪些在给定的 timestamps 中缺失。
|
||||
func missingTimestampNames(timestamps map[string]int64) []string {
|
||||
var missing []string
|
||||
for _, name := range requiredTimestampNames {
|
||||
if _, ok := timestamps[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
// 如果 timestamps 中同时存在 endName 和 beginName,则返回它们的差值;否则返回 nil。
|
||||
func subtractIfPresent(timestamps map[string]int64, endName, beginName string) *int64 {
|
||||
end, ok := timestamps[endName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
begin, ok := timestamps[beginName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := end - begin
|
||||
return &value
|
||||
}
|
||||
|
||||
func subtractSummaryTimestamps(endSummary *Summary, endName string, beginSummary *Summary, beginName string) *int64 {
|
||||
end, ok := endSummary.Timestamps[endName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
begin, ok := beginSummary.Timestamps[beginName]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := end - begin
|
||||
return &value
|
||||
}
|
||||
|
||||
// 除法函数,如果 bodySize <= 0 或 latencyNS 不存在或 <= 0,则返回 nil;否则返回 bodySize / latencyNS 的结果。
|
||||
func calculateBitrateBPS(bodySize int, latencyNS *int64) *float64 {
|
||||
if bodySize <= 0 || latencyNS == nil || *latencyNS <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
value := float64(bodySize) * 8 * 1_000_000_000 / float64(*latencyNS)
|
||||
return &value
|
||||
}
|
||||
|
||||
// 最大 message_id 计算函数
|
||||
func maxBusinessMessageID(events []Event) (uint64, bool) {
|
||||
var maxMessageID uint64
|
||||
hasBusinessMessage := false
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
if !hasBusinessMessage || event.MessageID > maxMessageID {
|
||||
maxMessageID = event.MessageID
|
||||
hasBusinessMessage = true
|
||||
}
|
||||
}
|
||||
|
||||
return maxMessageID, hasBusinessMessage
|
||||
}
|
||||
|
||||
// 根据 message_id 截断事件列表的函数
|
||||
func filterEventsByMaxMessageID(events []Event, maxMessageID uint64) []Event {
|
||||
filtered := make([]Event, 0, len(events))
|
||||
for _, event := range events {
|
||||
if event.MessageID > maxMessageID {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, event)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
func subtractUint64(value, offset uint64) (uint64, bool) {
|
||||
if offset > value {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return value - offset, true
|
||||
}
|
||||
|
||||
// 判断事件是否是业务相关的时延事件(其中一项)
|
||||
func IsBusinessEvent(event Event) bool {
|
||||
switch event.Event {
|
||||
case EventAAppPrepBegin,
|
||||
EventATXSched,
|
||||
EventATXSoftware,
|
||||
EventATXHardware,
|
||||
EventBRXHardware,
|
||||
EventBRXSoftware,
|
||||
EventBAppRecv,
|
||||
EventBPersistBegin,
|
||||
EventBPersistEnd:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
498
go/cmd/internal/latencylog/summary_chart.go
Normal file
498
go/cmd/internal/latencylog/summary_chart.go
Normal file
@@ -0,0 +1,498 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
const summaryChartHTMLTemplate = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Latency Summary Chart</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #60708a;
|
||||
--border: #d9dfeb;
|
||||
--track: #e8edf5;
|
||||
--a-proc: #3b82f6;
|
||||
--a-queue: #14b8a6;
|
||||
--transport: #f59e0b;
|
||||
--b-proc: #22c55e;
|
||||
--unknown: #94a3b8;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(180deg, #eef3ff 0%, var(--bg) 220px);
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 48px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.intro {
|
||||
color: var(--muted);
|
||||
margin: 0 0 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
box-shadow: 0 10px 30px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
.stat-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rows {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.row {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 12px 30px rgba(23, 32, 51, 0.05);
|
||||
}
|
||||
.row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.row-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.row-e2e {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ratio-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: -2px 0 12px;
|
||||
}
|
||||
.ratio-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: #f7f9fc;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.bar {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--track);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.segment {
|
||||
height: 100%;
|
||||
}
|
||||
.segment:first-child {
|
||||
border-top-left-radius: 999px;
|
||||
border-bottom-left-radius: 999px;
|
||||
}
|
||||
.segment:last-child {
|
||||
border-top-right-radius: 999px;
|
||||
border-bottom-right-radius: 999px;
|
||||
}
|
||||
.row-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.missing {
|
||||
margin-top: 10px;
|
||||
color: #a16207;
|
||||
font-size: 12px;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
padding: 24px 16px;
|
||||
background: var(--panel);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Latency Summary</h1>
|
||||
<p class="intro">A simple per-message end-to-end latency chart generated from summarized JSONL records.</p>
|
||||
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Messages</div>
|
||||
<div class="stat-value">{{.TotalMessages}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">With End-To-End</div>
|
||||
<div class="stat-value">{{.MessagesWithEndToEnd}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Average End-To-End</div>
|
||||
<div class="stat-value">{{.AverageEndToEnd}}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Max End-To-End</div>
|
||||
<div class="stat-value">{{.MaxEndToEnd}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="legend">
|
||||
{{range .Legend}}
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style="background: {{.Color}}"></span>
|
||||
<span>{{.Label}}</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Rows}}
|
||||
<section class="rows">
|
||||
{{range .Rows}}
|
||||
<article class="row">
|
||||
<div class="row-head">
|
||||
<h2 class="row-title">{{.Title}}</h2>
|
||||
<div class="row-e2e">{{.EndToEnd}}</div>
|
||||
</div>
|
||||
<div class="row-meta">{{.Subtitle}}</div>
|
||||
<div class="row-meta">{{.ApproxRTT}}</div>
|
||||
{{if .RatioMetrics}}
|
||||
<div class="ratio-list">
|
||||
{{range .RatioMetrics}}
|
||||
<span class="ratio-pill">{{.Label}} {{.Value}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="bar">
|
||||
{{range .Segments}}
|
||||
<div class="segment" style="width: {{printf "%.4f" .WidthPercent}}%; background: {{.Color}}" title="{{.Label}}: {{.Value}}"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Segments}}
|
||||
<div class="row-legend">
|
||||
{{range .Segments}}
|
||||
<span class="legend-item">
|
||||
<span class="swatch" style="background: {{.Color}}"></span>
|
||||
<span>{{.Label}} {{.Value}}</span>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .MissingTimestamps}}
|
||||
<div class="missing">Missing timestamps: {{.MissingTimestamps}}</div>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="empty">No summarized messages were available for chart rendering.</section>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
type summaryChartPage struct {
|
||||
TotalMessages int
|
||||
MessagesWithEndToEnd int
|
||||
AverageEndToEnd string
|
||||
MaxEndToEnd string
|
||||
Legend []summaryChartLegendItem
|
||||
Rows []summaryChartRow
|
||||
}
|
||||
|
||||
type summaryChartLegendItem struct {
|
||||
Label string
|
||||
Color string
|
||||
}
|
||||
|
||||
type summaryChartRow struct {
|
||||
Title string
|
||||
Subtitle string
|
||||
EndToEnd string
|
||||
ApproxRTT string
|
||||
MissingTimestamps string
|
||||
RatioMetrics []summaryChartValue
|
||||
Segments []summaryChartSegment
|
||||
}
|
||||
|
||||
type summaryChartSegment struct {
|
||||
Label string
|
||||
Value string
|
||||
Color string
|
||||
WidthPercent float64
|
||||
}
|
||||
|
||||
type summaryChartValue struct {
|
||||
Label string
|
||||
Value string
|
||||
}
|
||||
|
||||
type summaryChartSegmentMetric struct {
|
||||
label string
|
||||
value *int64
|
||||
color string
|
||||
}
|
||||
|
||||
// WriteSummariesHTMLChart 将整理结果写成一个可直接在浏览器中打开的简单 HTML 图表。
|
||||
func WriteSummariesHTMLChart(path string, summaries []Summary) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("latencylog: create chart dir for %s: %w", path, err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: open chart file %s: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
page := buildSummaryChartPage(summaries)
|
||||
tmpl, err := template.New("summary-chart").Parse(summaryChartHTMLTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("latencylog: parse chart template: %w", err)
|
||||
}
|
||||
|
||||
writer := bufio.NewWriter(file)
|
||||
if err := tmpl.Execute(writer, page); err != nil {
|
||||
return fmt.Errorf("latencylog: render chart %s: %w", path, err)
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return fmt.Errorf("latencylog: flush chart %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSummaryChartPage(summaries []Summary) summaryChartPage {
|
||||
page := summaryChartPage{
|
||||
TotalMessages: len(summaries),
|
||||
Legend: []summaryChartLegendItem{
|
||||
{Label: "A processing", Color: "var(--a-proc)"},
|
||||
{Label: "A queue", Color: "var(--a-queue)"},
|
||||
{Label: "A-B transport + propagation", Color: "var(--transport)"},
|
||||
{Label: "B processing", Color: "var(--b-proc)"},
|
||||
{Label: "Unknown / missing", Color: "var(--unknown)"},
|
||||
},
|
||||
Rows: make([]summaryChartRow, 0, len(summaries)),
|
||||
}
|
||||
|
||||
var (
|
||||
endToEndValues []int64
|
||||
totalEndToEnd int64
|
||||
maxEndToEnd int64
|
||||
)
|
||||
|
||||
for _, summary := range summaries {
|
||||
page.Rows = append(page.Rows, buildSummaryChartRow(summary))
|
||||
|
||||
if summary.EndToEndLatencyNS == nil {
|
||||
continue
|
||||
}
|
||||
endToEnd := *summary.EndToEndLatencyNS
|
||||
endToEndValues = append(endToEndValues, endToEnd)
|
||||
totalEndToEnd += endToEnd
|
||||
if endToEnd > maxEndToEnd {
|
||||
maxEndToEnd = endToEnd
|
||||
}
|
||||
}
|
||||
|
||||
page.MessagesWithEndToEnd = len(endToEndValues)
|
||||
page.AverageEndToEnd = "n/a"
|
||||
page.MaxEndToEnd = "n/a"
|
||||
if len(endToEndValues) > 0 {
|
||||
page.AverageEndToEnd = formatLatencyNS(totalEndToEnd / int64(len(endToEndValues)))
|
||||
page.MaxEndToEnd = formatLatencyNS(maxEndToEnd)
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
func buildSummaryChartRow(summary Summary) summaryChartRow {
|
||||
row := summaryChartRow{
|
||||
Title: buildSummaryChartTitle(summary),
|
||||
Subtitle: buildSummaryChartSubtitle(summary),
|
||||
EndToEnd: "End-to-end: n/a",
|
||||
ApproxRTT: "Approx RTT: n/a",
|
||||
MissingTimestamps: strings.Join(summary.MissingTimestamps, ", "),
|
||||
}
|
||||
if summary.ApproxRTTNS != nil && *summary.ApproxRTTNS > 0 {
|
||||
row.ApproxRTT = fmt.Sprintf("Approx RTT: %s", formatLatencyNS(*summary.ApproxRTTNS))
|
||||
}
|
||||
|
||||
ratioMetrics := []struct {
|
||||
label string
|
||||
value *float64
|
||||
}{
|
||||
{label: "A processing bitrate", value: summary.AProcessingBitrateBPS},
|
||||
{label: "A-B transport + propagation bitrate", value: summary.ABTransportPropagationBitrateBPS},
|
||||
{label: "End-to-end bitrate", value: summary.EndToEndBitrateBPS},
|
||||
}
|
||||
for _, metric := range ratioMetrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
row.RatioMetrics = append(row.RatioMetrics, summaryChartValue{
|
||||
Label: metric.label,
|
||||
Value: formatBitrateBPS(*metric.value),
|
||||
})
|
||||
}
|
||||
|
||||
if summary.EndToEndLatencyNS == nil || *summary.EndToEndLatencyNS <= 0 {
|
||||
return row
|
||||
}
|
||||
|
||||
total := *summary.EndToEndLatencyNS
|
||||
row.EndToEnd = fmt.Sprintf("End-to-end: %s", formatLatencyNS(total))
|
||||
|
||||
metrics := []summaryChartSegmentMetric{
|
||||
{label: "A processing", value: summary.AProcessingLatencyNS, color: "var(--a-proc)"},
|
||||
{label: "A queue", value: summary.AQueueLatencyNS, color: "var(--a-queue)"},
|
||||
{label: "A-B transport + propagation", value: summary.ABTransportPropagationNS, color: "var(--transport)"},
|
||||
{label: "B processing", value: summary.BProcessingLatencyNS, color: "var(--b-proc)"},
|
||||
}
|
||||
|
||||
var knownTotal int64
|
||||
for _, metric := range metrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
knownTotal += *metric.value
|
||||
}
|
||||
|
||||
scaleTotal := total
|
||||
if knownTotal > scaleTotal {
|
||||
scaleTotal = knownTotal
|
||||
}
|
||||
if scaleTotal <= 0 {
|
||||
return row
|
||||
}
|
||||
|
||||
for _, metric := range metrics {
|
||||
if metric.value == nil || *metric.value <= 0 {
|
||||
continue
|
||||
}
|
||||
row.Segments = append(row.Segments, summaryChartSegment{
|
||||
Label: metric.label,
|
||||
Value: formatLatencyNS(*metric.value),
|
||||
Color: metric.color,
|
||||
WidthPercent: float64(*metric.value) * 100 / float64(scaleTotal),
|
||||
})
|
||||
}
|
||||
|
||||
if remaining := total - knownTotal; remaining > 0 {
|
||||
row.Segments = append(row.Segments, summaryChartSegment{
|
||||
Label: "Unknown / missing",
|
||||
Value: formatLatencyNS(remaining),
|
||||
Color: "var(--unknown)",
|
||||
WidthPercent: float64(remaining) * 100 / float64(scaleTotal),
|
||||
})
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
func buildSummaryChartTitle(summary Summary) string {
|
||||
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
|
||||
return fmt.Sprintf("%s #%d (%s)", summary.MessageType, summary.MessageID, summary.FileName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s #%d", summary.MessageType, summary.MessageID)
|
||||
}
|
||||
|
||||
func buildSummaryChartSubtitle(summary Summary) string {
|
||||
parts := []string{
|
||||
fmt.Sprintf("%s -> %s", summary.From, summary.To),
|
||||
fmt.Sprintf("%d bytes", summary.BodySize),
|
||||
}
|
||||
|
||||
if summary.MessageType == protocol.MessageTypeFile && summary.FileName != "" {
|
||||
parts = append(parts, fmt.Sprintf("file: %s", summary.FileName))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
func formatLatencyNS(ns int64) string {
|
||||
return fmt.Sprintf("%.3f ms", float64(ns)/1_000_000)
|
||||
}
|
||||
|
||||
func formatBitrateBPS(bitsPerSecond float64) string {
|
||||
return fmt.Sprintf("%.3f Mb/s", bitsPerSecond/1_000_000)
|
||||
}
|
||||
79
go/cmd/internal/latencylog/summary_chart_test.go
Normal file
79
go/cmd/internal/latencylog/summary_chart_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestWriteSummariesHTMLChart(t *testing.T) {
|
||||
aProcessing := int64(20_000_000)
|
||||
aQueue := int64(10_000_000)
|
||||
transport := int64(40_000_000)
|
||||
bProcessing := int64(30_000_000)
|
||||
endToEnd := int64(100_000_000)
|
||||
aProcessingBitrate := float64(5) * 8 * 1_000_000_000 / float64(aProcessing)
|
||||
transportBitrate := float64(5) * 8 * 1_000_000_000 / float64(transport)
|
||||
endToEndBitrate := float64(5) * 8 * 1_000_000_000 / float64(endToEnd)
|
||||
|
||||
summaries := []Summary{
|
||||
{
|
||||
MessageType: protocol.MessageTypeText,
|
||||
MessageID: 7,
|
||||
From: "peer-a",
|
||||
To: "peer-b",
|
||||
BodySize: 5,
|
||||
AProcessingLatencyNS: &aProcessing,
|
||||
AQueueLatencyNS: &aQueue,
|
||||
ABTransportPropagationNS: &transport,
|
||||
BProcessingLatencyNS: &bProcessing,
|
||||
EndToEndLatencyNS: &endToEnd,
|
||||
AProcessingBitrateBPS: &aProcessingBitrate,
|
||||
ABTransportPropagationBitrateBPS: &transportBitrate,
|
||||
EndToEndBitrateBPS: &endToEndBitrate,
|
||||
ApproxRTTNS: &endToEnd,
|
||||
},
|
||||
{
|
||||
MessageType: protocol.MessageTypeFile,
|
||||
MessageID: 8,
|
||||
From: "peer-b",
|
||||
To: "peer-a",
|
||||
FileName: "payload.bin",
|
||||
BodySize: 128,
|
||||
MissingTimestamps: []string{EventBRXSoftware},
|
||||
},
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "charts", "latency-summary.html")
|
||||
if err := WriteSummariesHTMLChart(path, summaries); err != nil {
|
||||
t.Fatalf("WriteSummariesHTMLChart() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"Latency Summary",
|
||||
"text #7",
|
||||
"peer-a -> peer-b | 5 bytes",
|
||||
"End-to-end: 100.000 ms",
|
||||
"Approx RTT: 100.000 ms",
|
||||
"A processing bitrate 0.002 Mb/s",
|
||||
"A-B transport + propagation bitrate 0.001 Mb/s",
|
||||
"End-to-end bitrate 0.000 Mb/s",
|
||||
"A processing 20.000 ms",
|
||||
"A-B transport + propagation 40.000 ms",
|
||||
"file #8 (payload.bin)",
|
||||
"Missing timestamps: B_RX_SOFTWARE",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("chart content missing %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
399
go/cmd/internal/latencylog/summary_test.go
Normal file
399
go/cmd/internal/latencylog/summary_test.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package latencylog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"omnisocketgo/cmd/internal/protocol"
|
||||
)
|
||||
|
||||
func TestSummarizeEventsComputesLatencyMetrics(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 230, Event: EventBPersistBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summary count = %d, want 1", len(summaries))
|
||||
}
|
||||
|
||||
summary := summaries[0]
|
||||
if got := ptrValue(summary.AProcessingLatencyNS); got != 20 {
|
||||
t.Fatalf("AProcessingLatencyNS = %d, want 20", got)
|
||||
}
|
||||
if got := ptrValue(summary.AQueueLatencyNS); got != 20 {
|
||||
t.Fatalf("AQueueLatencyNS = %d, want 20", got)
|
||||
}
|
||||
if got := ptrValue(summary.ABTransportPropagationNS); got != 80 {
|
||||
t.Fatalf("ABTransportPropagationNS = %d, want 80", got)
|
||||
}
|
||||
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
|
||||
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.BProcessingLatencyNS); got != 40 {
|
||||
t.Fatalf("BProcessingLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
|
||||
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.AProcessingBitrateBPS); got != 128_000_000_000 {
|
||||
t.Fatalf("AProcessingBitrateBPS = %v, want 128000000000", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.ABTransportPropagationBitrateBPS); got != 32_000_000_000 {
|
||||
t.Fatalf("ABTransportPropagationBitrateBPS = %v, want 32000000000", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
|
||||
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
|
||||
}
|
||||
if got := summary.Timestamps[EventBRXSoftware]; got != 180 {
|
||||
t.Fatalf("timestamps[%q] = %d, want 180", EventBRXSoftware, got)
|
||||
}
|
||||
if len(summary.MissingTimestamps) != 0 {
|
||||
t.Fatalf("MissingTimestamps = %v, want empty", summary.MissingTimestamps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEventsComputesApproxRTTByPairingReverseMessages(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 110, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 180, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 120, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 190, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 200, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 210, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 260, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 11, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 220, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 230, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
{TsUnixNano: 310, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 12, From: "peer-b", To: "peer-a"},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 4 {
|
||||
t.Fatalf("summary count = %d, want 4", len(summaries))
|
||||
}
|
||||
|
||||
gotByMessageID := make(map[uint64]Summary, len(summaries))
|
||||
for _, summary := range summaries {
|
||||
gotByMessageID[summary.MessageID] = summary
|
||||
}
|
||||
|
||||
if got := ptrValue(gotByMessageID[1].ApproxRTTNS); got != 150 {
|
||||
t.Fatalf("message 1 ApproxRTTNS = %d, want 150", got)
|
||||
}
|
||||
if got := ptrValue(gotByMessageID[2].ApproxRTTNS); got != 170 {
|
||||
t.Fatalf("message 2 ApproxRTTNS = %d, want 170", got)
|
||||
}
|
||||
if gotByMessageID[11].ApproxRTTNS != nil {
|
||||
t.Fatalf("message 11 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[11].ApproxRTTNS))
|
||||
}
|
||||
if gotByMessageID[12].ApproxRTTNS != nil {
|
||||
t.Fatalf("message 12 ApproxRTTNS = %d, want nil", ptrValue(gotByMessageID[12].ApproxRTTNS))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEventsReportsMissingTimestamps(t *testing.T) {
|
||||
events := []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
{TsUnixNano: 240, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b"},
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if len(summaries) != 1 {
|
||||
t.Fatalf("summary count = %d, want 1", len(summaries))
|
||||
}
|
||||
|
||||
wantMissing := []string{EventATXSched, EventATXSoftware, EventBRXSoftware, EventBAppRecv}
|
||||
if !reflect.DeepEqual(summaries[0].MissingTimestamps, wantMissing) {
|
||||
t.Fatalf("MissingTimestamps = %v, want %v", summaries[0].MissingTimestamps, wantMissing)
|
||||
}
|
||||
if summaries[0].AProcessingLatencyNS != nil {
|
||||
t.Fatalf("AProcessingLatencyNS = %v, want nil", ptrValue(summaries[0].AProcessingLatencyNS))
|
||||
}
|
||||
if summaries[0].EndToEndLatencyNS == nil {
|
||||
t.Fatal("EndToEndLatencyNS = nil, want non-nil because endpoints are present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAndWriteSummaryFiles(t *testing.T) {
|
||||
rawPath := filepath.Join(t.TempDir(), "raw.jsonl")
|
||||
rawLogger, err := NewJSONLLogger(rawPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewJSONLLogger() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rawLogger.Close()
|
||||
})
|
||||
|
||||
for _, event := range []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
} {
|
||||
if err := rawLogger.LogEvent(event); err != nil {
|
||||
t.Fatalf("LogEvent() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
events, err := LoadEventsFromFile(rawPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFile() error = %v", err)
|
||||
}
|
||||
|
||||
summaryPath := filepath.Join(t.TempDir(), "summary.jsonl")
|
||||
if err := WriteSummariesJSONL(summaryPath, SummarizeEvents(events)); err != nil {
|
||||
t.Fatalf("WriteSummariesJSONL() error = %v", err)
|
||||
}
|
||||
|
||||
file, err := os.Open(summaryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("os.Open() error = %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("expected one summary line, got none")
|
||||
}
|
||||
|
||||
var summary Summary
|
||||
if err := json.Unmarshal(scanner.Bytes(), &summary); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if summary.MessageID != 3 {
|
||||
t.Fatalf("MessageID = %d, want 3", summary.MessageID)
|
||||
}
|
||||
if got := ptrValue(summary.BKernelReceivePathLatencyNS); got != 40 {
|
||||
t.Fatalf("BKernelReceivePathLatencyNS = %d, want 40", got)
|
||||
}
|
||||
if got := ptrValue(summary.EndToEndLatencyNS); got != 160 {
|
||||
t.Fatalf("EndToEndLatencyNS = %d, want 160", got)
|
||||
}
|
||||
if got := ptrValueFloat(summary.EndToEndBitrateBPS); got != 16_000_000_000 {
|
||||
t.Fatalf("EndToEndBitrateBPS = %v, want 16000000000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEventsFromFilesWithSharedMaxOffsetFiltersToSharedCutoff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
firstMessageIDs []uint64
|
||||
secondMessageIDs []uint64
|
||||
offset uint64
|
||||
wantCutoff *uint64
|
||||
wantMessageIDs []uint64
|
||||
}{
|
||||
{
|
||||
name: "same max message id rolls back one",
|
||||
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(6),
|
||||
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "smaller input max wins before rollback",
|
||||
firstMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
secondMessageIDs: []uint64{1, 2, 3, 4, 5, 6, 7},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(6),
|
||||
wantMessageIDs: []uint64{1, 2, 3, 4, 5, 6},
|
||||
},
|
||||
{
|
||||
name: "not enough shared messages yields empty result",
|
||||
firstMessageIDs: []uint64{1},
|
||||
secondMessageIDs: []uint64{1},
|
||||
offset: 1,
|
||||
wantCutoff: uint64Ptr(0),
|
||||
wantMessageIDs: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range testCases {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
firstPath := filepath.Join(tempDir, "first.jsonl")
|
||||
secondPath := filepath.Join(tempDir, "second.jsonl")
|
||||
writeEventsJSONL(t, firstPath, testEventsForMessageIDs(tt.firstMessageIDs, "peer-a", "peer-b"))
|
||||
writeEventsJSONL(t, secondPath, testEventsForMessageIDs(tt.secondMessageIDs, "peer-b", "peer-a"))
|
||||
|
||||
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, tt.offset)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cutoff, tt.wantCutoff) {
|
||||
t.Fatalf("cutoff = %v, want %v", cutoff, tt.wantCutoff)
|
||||
}
|
||||
|
||||
if got := businessMessageIDs(events); !reflect.DeepEqual(got, tt.wantMessageIDs) {
|
||||
t.Fatalf("message IDs = %v, want %v", got, tt.wantMessageIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEventsFromFilesWithSharedMaxOffsetPreservesEarlierSummaries(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
firstPath := filepath.Join(tempDir, "first.jsonl")
|
||||
secondPath := filepath.Join(tempDir, "second.jsonl")
|
||||
|
||||
writeEventsJSONL(t, firstPath, []Event{
|
||||
{TsUnixNano: 100, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 120, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 140, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 180, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 220, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 260, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-a", To: "peer-b", BodySize: 320},
|
||||
{TsUnixNano: 300, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 330, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 360, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 390, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 420, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 470, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-a", To: "peer-b", BodySize: 160},
|
||||
{TsUnixNano: 500, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 520, Event: EventATXSched, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 540, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 560, Event: EventBRXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 580, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 600, Event: EventBPersistEnd, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-a", To: "peer-b", BodySize: 80},
|
||||
{TsUnixNano: 700, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-a", To: "peer-b", BodySize: 40},
|
||||
})
|
||||
writeEventsJSONL(t, secondPath, []Event{
|
||||
{TsUnixNano: 90, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 95, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 150, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 1, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 290, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 295, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 350, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 2, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 490, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 495, Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 550, Event: EventBAppRecv, MessageType: protocol.MessageTypeText, MessageID: 3, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
{TsUnixNano: 690, Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: 4, From: "peer-b", To: "peer-a", BodySize: 20},
|
||||
})
|
||||
|
||||
events, cutoff, err := LoadEventsFromFilesWithSharedMaxOffset([]string{firstPath, secondPath}, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsFromFilesWithSharedMaxOffset() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(cutoff, uint64Ptr(3)) {
|
||||
t.Fatalf("cutoff = %v, want %v", cutoff, uint64Ptr(3))
|
||||
}
|
||||
|
||||
summaries := SummarizeEvents(events)
|
||||
if got := len(summaries); got != 6 {
|
||||
t.Fatalf("summary count = %d, want 6", got)
|
||||
}
|
||||
|
||||
for _, summary := range summaries {
|
||||
if summary.MessageID == 4 {
|
||||
t.Fatalf("message 4 should have been truncated from summaries: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
var forwardMessageTwo Summary
|
||||
found := false
|
||||
for _, summary := range summaries {
|
||||
if summary.From == "peer-a" && summary.To == "peer-b" && summary.MessageID == 2 {
|
||||
forwardMessageTwo = summary
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("summary for message 2 peer-a -> peer-b not found")
|
||||
}
|
||||
if got := ptrValue(forwardMessageTwo.EndToEndLatencyNS); got != 170 {
|
||||
t.Fatalf("message 2 EndToEndLatencyNS = %d, want 170", got)
|
||||
}
|
||||
if got := ptrValue(forwardMessageTwo.ApproxRTTNS); got != 190 {
|
||||
t.Fatalf("message 2 ApproxRTTNS = %d, want 190", got)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func ptrValueFloat(value *float64) float64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func uint64Ptr(value uint64) *uint64 {
|
||||
return &value
|
||||
}
|
||||
|
||||
func businessMessageIDs(events []Event) []uint64 {
|
||||
seen := make(map[uint64]struct{})
|
||||
var ids []uint64
|
||||
|
||||
for _, event := range events {
|
||||
if !IsBusinessEvent(event) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[event.MessageID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[event.MessageID] = struct{}{}
|
||||
ids = append(ids, event.MessageID)
|
||||
}
|
||||
|
||||
sort.Slice(ids, func(i, j int) bool {
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
func testEventsForMessageIDs(messageIDs []uint64, from, to string) []Event {
|
||||
events := make([]Event, 0, len(messageIDs)*2)
|
||||
for _, messageID := range messageIDs {
|
||||
events = append(events,
|
||||
Event{TsUnixNano: int64(messageID*100 + 10), Event: EventAAppPrepBegin, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
|
||||
Event{TsUnixNano: int64(messageID*100 + 20), Event: EventATXSoftware, MessageType: protocol.MessageTypeText, MessageID: messageID, From: from, To: to, BodySize: 32},
|
||||
)
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func writeEventsJSONL(t *testing.T, path string, events []Event) {
|
||||
t.Helper()
|
||||
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
t.Fatalf("os.OpenFile(%s) error = %v", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, event := range events {
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
t.Fatalf("encoder.Encode(%s) error = %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user