diff --git a/main.go b/main.go index 5fb9836..adc4739 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,10 @@ service: receivers: [otlp] processors: [] exporters: [tui] + logs: + receivers: [otlp] + processors: [] + exporters: [tui] ` provider := yamlprovider.New() diff --git a/tuiexporter/exporter.go b/tuiexporter/exporter.go index f52b4ca..76b9d44 100644 --- a/tuiexporter/exporter.go +++ b/tuiexporter/exporter.go @@ -7,6 +7,7 @@ import ( "github.com/ymtdzzz/otel-tui/tuiexporter/internal/telemetry" "github.com/ymtdzzz/otel-tui/tuiexporter/internal/tui" "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/pdata/plog" "go.opentelemetry.io/collector/pdata/ptrace" ) @@ -26,6 +27,12 @@ func (e *tuiExporter) pushTraces(_ context.Context, traces ptrace.Traces) error return nil } +func (e *tuiExporter) pushLogs(_ context.Context, logs plog.Logs) error { + e.app.Store().AddLog(&logs) + + return nil +} + // Start runs the TUI exporter func (e *tuiExporter) Start(_ context.Context, _ component.Host) error { go func() { diff --git a/tuiexporter/factory.go b/tuiexporter/factory.go index ed2facc..51c7960 100644 --- a/tuiexporter/factory.go +++ b/tuiexporter/factory.go @@ -20,7 +20,7 @@ func NewFactory() exporter.Factory { createDefaultConfig, exporter.WithTraces(createTraces, stability), //exporter.WithMetrics(createMetrics, stability), - //exporter.WithLogs(createLog, stability), + exporter.WithLogs(createLogs, stability), ) } @@ -49,6 +49,27 @@ func createTraces(ctx context.Context, set exporter.CreateSettings, cfg componen ) } +func createLogs(ctx context.Context, set exporter.CreateSettings, cfg component.Config) (exporter.Logs, error) { + oCfg := cfg.(*Config) + + e, err := exporters.LoadOrStore( + oCfg, + func() (*tuiExporter, error) { + return newTuiExporter(oCfg), nil + }, + &set.TelemetrySettings, + ) + if err != nil { + return nil, err + } + + return exporterhelper.NewLogsExporter(ctx, set, oCfg, + e.Unwrap().pushLogs, + exporterhelper.WithStart(e.Start), + exporterhelper.WithShutdown(e.Shutdown), + ) +} + // This is the map of already created OTLP receivers for particular configurations. // We maintain this map because the Factory is asked trace and metric receivers separately // when it gets CreateTracesReceiver() and CreateMetricsReceiver() but they must not diff --git a/tuiexporter/internal/telemetry/cache.go b/tuiexporter/internal/telemetry/cache.go index 75419dc..0934cc9 100644 --- a/tuiexporter/internal/telemetry/cache.go +++ b/tuiexporter/internal/telemetry/cache.go @@ -12,6 +12,10 @@ type TraceSpanDataMap map[string][]*SpanData // This is used to quickly look up all spans in a trace for a service type TraceServiceSpanDataMap map[string]map[string][]*SpanData +// TraceLogDataMap is a map of trace id to a slice of logs +// This is used to quickly look up all logs in a trace +type TraceLogDataMap map[string][]*LogData + // TraceCache is a cache of trace spans type TraceCache struct { spanid2span SpanDataMap @@ -98,3 +102,50 @@ func (c *TraceCache) flush() { c.traceid2spans = TraceSpanDataMap{} c.tracesvc2spans = TraceServiceSpanDataMap{} } + +// LogCache is a cache of logs +type LogCache struct { + traceid2logs TraceLogDataMap +} + +// NewLogCache returns a new log cache +func NewLogCache() *LogCache { + return &LogCache{ + traceid2logs: TraceLogDataMap{}, + } +} + +// UpdateCache updates the cache with a new log +func (c *LogCache) UpdateCache(data *LogData) { + traceID := data.Log.TraceID().String() + if ts, ok := c.traceid2logs[traceID]; ok { + c.traceid2logs[traceID] = append(ts, data) + } else { + c.traceid2logs[traceID] = []*LogData{data} + } +} + +// DeleteCache deletes a list of logs from the cache +func (c *LogCache) DeleteCache(logs []*LogData) { + for _, l := range logs { + traceID := l.Log.TraceID().String() + if _, ok := c.traceid2logs[traceID]; ok { + for i, log := range c.traceid2logs[traceID] { + if log == l { + c.traceid2logs[traceID] = append(c.traceid2logs[traceID][:i], c.traceid2logs[traceID][i+1:]...) + break + } + } + } + } +} + +// GetLogsByTraceID returns all logs for a given trace id +func (c *LogCache) GetLogsByTraceID(traceID string) ([]*LogData, bool) { + logs, ok := c.traceid2logs[traceID] + return logs, ok +} + +func (c *LogCache) flush() { + c.traceid2logs = TraceLogDataMap{} +} diff --git a/tuiexporter/internal/telemetry/cache_test.go b/tuiexporter/internal/telemetry/cache_test.go index c449610..7b925ce 100644 --- a/tuiexporter/internal/telemetry/cache_test.go +++ b/tuiexporter/internal/telemetry/cache_test.go @@ -8,7 +8,7 @@ import ( func TestGetSpansByTraceID(t *testing.T) { c := NewTraceCache() - spans := []*SpanData{{}} + spans := []*SpanData{} c.traceid2spans["traceid"] = spans tests := []struct { @@ -42,7 +42,7 @@ func TestGetSpansByTraceID(t *testing.T) { func TestGetSpansByTraceIDAndSvc(t *testing.T) { c := NewTraceCache() - spans := []*SpanData{{}} + spans := []*SpanData{} c.tracesvc2spans["traceid"] = map[string][]*SpanData{"svc-name": spans} tests := []struct { @@ -117,3 +117,37 @@ func TestGetSpanByID(t *testing.T) { }) } } + +func TestGetLogsByTraceID(t *testing.T) { + c := NewLogCache() + logs := []*LogData{} + c.traceid2logs["traceid"] = logs + + tests := []struct { + name string + traceID string + wantdata []*LogData + wantok bool + }{ + { + name: "traceid exists", + traceID: "traceid", + wantdata: logs, + wantok: true, + }, + { + name: "traceid does not exist", + traceID: "traceid2", + wantdata: nil, + wantok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotdata, gotok := c.GetLogsByTraceID(tt.traceID) + assert.Equal(t, tt.wantdata, gotdata) + assert.Equal(t, tt.wantok, gotok) + }) + } +} diff --git a/tuiexporter/internal/telemetry/store.go b/tuiexporter/internal/telemetry/store.go index d3d35a6..724dd9a 100644 --- a/tuiexporter/internal/telemetry/store.go +++ b/tuiexporter/internal/telemetry/store.go @@ -5,11 +5,16 @@ import ( "sync" "time" + "go.opentelemetry.io/collector/pdata/plog" "go.opentelemetry.io/collector/pdata/ptrace" ) -const MAX_SERVICE_SPAN_COUNT = 1000 +const ( + MAX_SERVICE_SPAN_COUNT = 1000 + MAX_LOG_COUNT = 1000 +) +// SpanData is a struct to represent a span type SpanData struct { Span *ptrace.Span ResourceSpan *ptrace.ResourceSpans @@ -26,31 +31,48 @@ func (sd *SpanData) IsRoot() bool { // This is a slice of one span of a single service type SvcSpans []*SpanData +// LogData is a struct to represent a log +type LogData struct { + Log *plog.LogRecord + ResourceLog *plog.ResourceLogs + ScopeLog *plog.ScopeLogs + ReceivedAt time.Time +} + // Store is a store of trace spans type Store struct { mut sync.Mutex filterSvc string + filterLog string svcspans SvcSpans svcspansFiltered SvcSpans - cache *TraceCache + tracecache *TraceCache + logs []*LogData + logsFiltered []*LogData + logcache *LogCache updatedAt time.Time maxServiceSpanCount int + maxLogCount int } // NewStore creates a new store func NewStore() *Store { return &Store{ mut: sync.Mutex{}, - svcspans: []*SpanData{}, - svcspansFiltered: []*SpanData{}, - cache: NewTraceCache(), + svcspans: SvcSpans{}, + svcspansFiltered: SvcSpans{}, + tracecache: NewTraceCache(), + logs: []*LogData{}, + logsFiltered: []*LogData{}, + logcache: NewLogCache(), maxServiceSpanCount: MAX_SERVICE_SPAN_COUNT, // TODO: make this configurable + maxLogCount: MAX_LOG_COUNT, // TODO: make this configurable } } // GetCache returns the cache func (s *Store) GetCache() *TraceCache { - return s.cache + return s.tracecache } // GetSvcSpans returns the service spans in the store @@ -63,6 +85,11 @@ func (s *Store) GetFilteredSvcSpans() *SvcSpans { return &s.svcspansFiltered } +// GetFilteredLogs returns the filtered logs in the store +func (s *Store) GetFilteredLogs() *[]*LogData { + return &s.logsFiltered +} + // UpdatedAt returns the last updated time func (s *Store) UpdatedAt() time.Time { return s.updatedAt @@ -90,6 +117,29 @@ func (s *Store) updateFilterService() { s.ApplyFilterService(s.filterSvc) } +// ApplyFilterLogs applies a filter to the logs +func (s *Store) ApplyFilterLogs(filter string) { + s.filterLog = filter + s.logsFiltered = []*LogData{} + + if filter == "" { + s.logsFiltered = s.logs + return + } + + for _, log := range s.logs { + sname, _ := log.ResourceLog.Resource().Attributes().Get("service.name") + target := sname.AsString() + " " + log.Log.Body().AsString() + if strings.Contains(target, filter) { + s.logsFiltered = append(s.logsFiltered, log) + } + } +} + +func (s *Store) updateFilterLogs() { + s.ApplyFilterLogs(s.filterLog) +} + // GetTraceIDByFilteredIdx returns the trace at the given index func (s *Store) GetTraceIDByFilteredIdx(idx int) string { if idx >= 0 && idx < len(s.svcspansFiltered) { @@ -106,11 +156,19 @@ func (s *Store) GetFilteredServiceSpansByIdx(idx int) []*SpanData { span := s.svcspansFiltered[idx] traceID := span.Span.TraceID().String() sname, _ := span.ResourceSpan.Resource().Attributes().Get("service.name") - spans, _ := s.cache.GetSpansByTraceIDAndSvc(traceID, sname.AsString()) + spans, _ := s.tracecache.GetSpansByTraceIDAndSvc(traceID, sname.AsString()) return spans } +// GetFilteredLogByIdx returns the log at the given index +func (s *Store) GetFilteredLogByIdx(idx int) *LogData { + if idx < 0 || idx >= len(s.logsFiltered) { + return nil + } + return s.logsFiltered[idx] +} + // AddSpan adds a span to the store func (s *Store) AddSpan(traces *ptrace.Traces) { s.mut.Lock() @@ -136,7 +194,7 @@ func (s *Store) AddSpan(traces *ptrace.Traces) { ScopeSpans: &ss, ReceivedAt: time.Now(), } - newtracesvc := s.cache.UpdateCache(sname.AsString(), sd) + newtracesvc := s.tracecache.UpdateCache(sname.AsString(), sd) if newtracesvc { s.svcspans = append(s.svcspans, sd) } @@ -148,7 +206,7 @@ func (s *Store) AddSpan(traces *ptrace.Traces) { if len(s.svcspans) > s.maxServiceSpanCount { deleteSpans := s.svcspans[:len(s.svcspans)-s.maxServiceSpanCount] - s.cache.DeleteCache(deleteSpans) + s.tracecache.DeleteCache(deleteSpans) s.svcspans = s.svcspans[len(s.svcspans)-s.maxServiceSpanCount:] } @@ -156,6 +214,45 @@ func (s *Store) AddSpan(traces *ptrace.Traces) { s.updateFilterService() } +// AddLog adds a log to the store +func (s *Store) AddLog(logs *plog.Logs) { + s.mut.Lock() + defer func() { + s.updatedAt = time.Now() + s.mut.Unlock() + }() + + for rli := 0; rli < logs.ResourceLogs().Len(); rli++ { + rl := logs.ResourceLogs().At(rli) + + for sli := 0; sli < rl.ScopeLogs().Len(); sli++ { + sl := rl.ScopeLogs().At(sli) + + for li := 0; li < sl.LogRecords().Len(); li++ { + lr := sl.LogRecords().At(li) + ld := &LogData{ + Log: &lr, + ResourceLog: &rl, + ScopeLog: &sl, + ReceivedAt: time.Now(), + } + s.logs = append(s.logs, ld) + s.logcache.UpdateCache(ld) + } + } + } + + // data rotation + if len(s.logs) > s.maxLogCount { + deleteLogs := s.logs[:len(s.logs)-s.maxLogCount] + s.logs = s.logs[len(s.logs)-s.maxLogCount:] + + s.logcache.DeleteCache(deleteLogs) + } + + s.updateFilterLogs() +} + // Flush clears the store including the cache func (s *Store) Flush() { s.mut.Lock() @@ -166,6 +263,9 @@ func (s *Store) Flush() { s.svcspans = SvcSpans{} s.svcspansFiltered = SvcSpans{} - s.cache.flush() + s.tracecache.flush() + s.logs = []*LogData{} + s.logsFiltered = []*LogData{} + s.logcache.flush() s.updatedAt = time.Now() } diff --git a/tuiexporter/internal/telemetry/store_test.go b/tuiexporter/internal/telemetry/store_test.go index 38f768e..c1d77b1 100644 --- a/tuiexporter/internal/telemetry/store_test.go +++ b/tuiexporter/internal/telemetry/store_test.go @@ -10,7 +10,7 @@ import ( ) func TestSpanDataIsRoot(t *testing.T) { - _, testdata := test.GenerateOTLPPayload(t, 1, 1, []int{1}, [][]int{{2}}) + _, testdata := test.GenerateOTLPTracesPayload(t, 1, 1, []int{1}, [][]int{{2}}) parentSpan := testdata.Spans[0] childSpan := testdata.Spans[1] parentSpan.SetParentSpanID(pcommon.SpanID{}) @@ -42,13 +42,14 @@ func TestSpanDataIsRoot(t *testing.T) { func TestStoreGetter(t *testing.T) { store := NewStore() - assert.Equal(t, store.cache, store.GetCache()) + assert.Equal(t, store.tracecache, store.GetCache()) assert.Equal(t, &store.svcspans, store.GetSvcSpans()) assert.Equal(t, &store.svcspansFiltered, store.GetFilteredSvcSpans()) + assert.Equal(t, &store.logsFiltered, store.GetFilteredLogs()) assert.Equal(t, store.updatedAt, store.UpdatedAt()) } -func TestStoreFilters(t *testing.T) { +func TestStoreSpanFilters(t *testing.T) { // traceid: 1 // └- resource: test-service-1 // | └- scope: test-scope-1-1 @@ -60,7 +61,7 @@ func TestStoreFilters(t *testing.T) { // └- scope: test-scope-2-1 // └- span: span-2-1-1 store := NewStore() - payload, testdata := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + payload, testdata := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) traceID := testdata.Spans[0].TraceID().String() store.AddSpan(&payload) @@ -105,6 +106,69 @@ func TestStoreFilters(t *testing.T) { } } +func TestStoreLogFilters(t *testing.T) { + // traceid: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- span: span-1-1-1 + // | | | └- log: log-1-1-1-1 + // | | | └- log: log-1-1-1-2 + // | | └- span: span-1-1-2 + // | | └- log: log-1-1-2-1 + // | | └- log: log-1-1-2-2 + // | └- scope: test-scope-1-2 + // | └- span: span-1-2-3 + // | └- log: log-1-2-3-1 + // | └- log: log-1-2-3-2 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- span: span-2-1-1 + // └- log: log-2-1-1-1 + // └- log: log-2-1-1-2 + store := NewStore() + payload, testdata := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddLog(&payload) + + store.ApplyFilterLogs("service-2") + assert.Equal(t, 2, len(store.logsFiltered)) + store.ApplyFilterLogs("log body 1-0-0-0") + assert.Equal(t, 1, len(store.logsFiltered)) + + tests := []struct { + name string + idx int + want *LogData + }{ + { + name: "invalid index", + idx: 1, + want: nil, + }, + { + name: "valid index", + idx: 0, + want: &LogData{ + Log: testdata.Logs[6], // span-2-1-1 + ResourceLog: testdata.RLogs[1], // test-service-2 + ScopeLog: testdata.SLogs[2], // test-scope-2-1 + }, + }, + } + + for _, tt := range tests { + t.Run("GetFilteredLogByIdx_"+tt.name, func(t *testing.T) { + got := store.GetFilteredLogByIdx(tt.idx) + if tt.want != nil { + assert.Equal(t, tt.want.Log, got.Log) + assert.Equal(t, tt.want.ResourceLog, got.ResourceLog) + assert.Equal(t, tt.want.ScopeLog, got.ScopeLog) + } else { + assert.Nil(t, got) + } + }) + } +} + func TestStoreAddSpanWithoutRotation(t *testing.T) { // traceid: 1 // └- resource: test-service-1 @@ -119,7 +183,7 @@ func TestStoreAddSpanWithoutRotation(t *testing.T) { store := NewStore() store.maxServiceSpanCount = 2 // no rotation before := store.updatedAt - payload, testdata := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + payload, testdata := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) store.AddSpan(&payload) assert.Equal(t, "", store.filterSvc) @@ -140,15 +204,15 @@ func TestStoreAddSpanWithoutRotation(t *testing.T) { assert.Equal(t, testdata.Spans[3], store.svcspansFiltered[1].Span) // span-2-1-1 // assert cache spanid2span - assert.Equal(t, 4, len(store.cache.spanid2span)) + assert.Equal(t, 4, len(store.tracecache.spanid2span)) for _, span := range testdata.Spans { - got := store.cache.spanid2span[span.SpanID().String()] + got := store.tracecache.spanid2span[span.SpanID().String()] assert.Equal(t, span, got.Span) } // assert cache traceid2spans { - gotsd := store.cache.traceid2spans[testdata.Spans[0].TraceID().String()] + gotsd := store.tracecache.traceid2spans[testdata.Spans[0].TraceID().String()] assert.Equal(t, 4, len(gotsd)) gotspans := []*ptrace.Span{} for _, sd := range gotsd { @@ -159,8 +223,8 @@ func TestStoreAddSpanWithoutRotation(t *testing.T) { // assert cache tracesvc2spans { - assert.Equal(t, 1, len(store.cache.tracesvc2spans)) - gotsds := store.cache.tracesvc2spans[testdata.Spans[0].TraceID().String()] + assert.Equal(t, 1, len(store.tracecache.tracesvc2spans)) + gotsds := store.tracecache.tracesvc2spans[testdata.Spans[0].TraceID().String()] assert.Equal(t, 2, len(gotsds)) assert.Equal(t, testdata.Spans[0], gotsds["test-service-1"][0].Span) // span-1-1-1 assert.Equal(t, testdata.Spans[1], gotsds["test-service-1"][1].Span) // span-1-1-2 @@ -186,8 +250,8 @@ func TestStoreAddSpanWithRotation(t *testing.T) { // └- span: span-1-1-1 store := NewStore() store.maxServiceSpanCount = 1 - payload1, _ := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) - payload2, testdata2 := test.GenerateOTLPPayload(t, 2, 1, []int{1}, [][]int{{1}}) + payload1, _ := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + payload2, testdata2 := test.GenerateOTLPTracesPayload(t, 2, 1, []int{1}, [][]int{{1}}) store.AddSpan(&payload1) store.AddSpan(&payload2) @@ -203,58 +267,177 @@ func TestStoreAddSpanWithRotation(t *testing.T) { assert.Equal(t, testdata2.Spans[0], store.svcspansFiltered[0].Span) // trace 2, span-1-1-1 // assert cache spanid2span - assert.Equal(t, 1, len(store.cache.spanid2span)) + assert.Equal(t, 1, len(store.tracecache.spanid2span)) { want := testdata2.Spans[0] - got := store.cache.spanid2span[want.SpanID().String()] + got := store.tracecache.spanid2span[want.SpanID().String()] assert.Equal(t, want, got.Span) } // assert cache traceid2spans { - gotsd := store.cache.traceid2spans[testdata2.Spans[0].TraceID().String()] + gotsd := store.tracecache.traceid2spans[testdata2.Spans[0].TraceID().String()] assert.Equal(t, 1, len(gotsd)) assert.Equal(t, testdata2.Spans[0], gotsd[0].Span) } // assert cache tracesvc2spans { - assert.Equal(t, 1, len(store.cache.tracesvc2spans)) - gotsds := store.cache.tracesvc2spans[testdata2.Spans[0].TraceID().String()] + assert.Equal(t, 1, len(store.tracecache.tracesvc2spans)) + gotsds := store.tracecache.tracesvc2spans[testdata2.Spans[0].TraceID().String()] assert.Equal(t, 1, len(gotsds)) assert.Equal(t, testdata2.Spans[0], gotsds["test-service-1"][0].Span) // trace 2, span-1-1-1 } } +func TestStoreAddLogWithoutRotation(t *testing.T) { + // traceid: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- span: span-1-1-1 + // | | | └- log: log-1-1-1-1 + // | | | └- log: log-1-1-1-2 + // | | └- span: span-1-1-2 + // | | └- log: log-1-1-2-1 + // | | └- log: log-1-1-2-2 + // | └- scope: test-scope-1-2 + // | └- span: span-1-2-3 + // | └- log: log-1-2-3-1 + // | └- log: log-1-2-3-2 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- span: span-2-1-1 + // └- log: log-2-1-1-1 + // └- log: log-2-1-1-2 + store := NewStore() + store.maxLogCount = 8 // no rotation + before := store.updatedAt + payload, testdata := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddLog(&payload) + + assert.Equal(t, "", store.filterSvc) + assert.True(t, before.Before(store.updatedAt)) + + // assert logs + assert.Equal(t, 8, len(store.logs)) + assert.Equal(t, testdata.Logs[0], store.logs[0].Log) // span-1-1-1 + assert.Equal(t, testdata.RLogs[0], store.logs[0].ResourceLog) // test-service-1 + assert.Equal(t, testdata.SLogs[0], store.logs[0].ScopeLog) // test-scope-1-1 + assert.Equal(t, testdata.Logs[6], store.logs[6].Log) // span-2-1-1 + assert.Equal(t, testdata.RLogs[1], store.logs[6].ResourceLog) // test-service-2 + assert.Equal(t, testdata.SLogs[2], store.logs[6].ScopeLog) // test-scope-2-1 + + // assert logsFiltered + assert.Equal(t, 8, len(store.logsFiltered)) + assert.Equal(t, testdata.Logs[0], store.logsFiltered[0].Log) // span-1-1-1 + assert.Equal(t, testdata.Logs[6], store.logsFiltered[6].Log) // span-2-1-1 + + // assert cache traceid2logs + assert.Equal(t, 1, len(store.logcache.traceid2logs)) + traceID := testdata.Logs[0].TraceID().String() + assert.Equal(t, 8, len(store.logcache.traceid2logs[traceID])) + for _, got := range store.logcache.traceid2logs[traceID] { + assert.Contains(t, testdata.Logs, got.Log) + } +} + +func TestStoreAddLogWithRotation(t *testing.T) { + // traceid: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- span: span-1-1-1 + // | | | └- log: log-1-1-1-1 + // | | | └- log: log-1-1-1-2 + // | | └- span: span-1-1-2 + // | | └- log: log-1-1-2-1 + // | | └- log: log-1-1-2-2 + // | └- scope: test-scope-1-2 + // | └- span: span-1-2-3 + // | └- log: log-1-2-3-1 + // | └- log: log-1-2-3-2 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- span: span-2-1-1 + // └- log: log-2-1-1-1 + // └- log: log-2-1-1-2 + store := NewStore() + store.maxLogCount = 1 + before := store.updatedAt + payload, testdata := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddLog(&payload) + + assert.Equal(t, "", store.filterSvc) + assert.True(t, before.Before(store.updatedAt)) + + // assert logs + assert.Equal(t, 1, len(store.logs)) + assert.Equal(t, testdata.Logs[7], store.logs[0].Log) // span-2-1-1 + assert.Equal(t, testdata.RLogs[1], store.logs[0].ResourceLog) // test-service-2 + assert.Equal(t, testdata.SLogs[2], store.logs[0].ScopeLog) // test-scope-2-1 + + // assert logsFiltered + assert.Equal(t, 1, len(store.logsFiltered)) + assert.Equal(t, testdata.Logs[7], store.logsFiltered[0].Log) // span-2-1-1 + + // assert cache traceid2logs + assert.Equal(t, 1, len(store.logcache.traceid2logs)) + traceID := testdata.Logs[0].TraceID().String() + assert.Equal(t, 1, len(store.logcache.traceid2logs[traceID])) + for _, got := range store.logcache.traceid2logs[traceID] { + assert.Contains(t, testdata.Logs, got.Log) + } +} + func TestStoreFlush(t *testing.T) { // traceid: 1 // └- resource: test-service-1 // | └- scope: test-scope-1-1 // | | └- span: span-1-1-1 + // | | | └- log: log-1-1-1-1 + // | | | └- log: log-1-1-1-2 // | | └- span: span-1-1-2 + // | | └- log: log-1-1-2-1 + // | | └- log: log-1-1-2-2 // | └- scope: test-scope-1-2 // | └- span: span-1-2-3 + // | └- log: log-1-2-3-1 + // | └- log: log-1-2-3-2 // └- resource: test-service-2 // └- scope: test-scope-2-1 // └- span: span-2-1-1 + // └- log: log-2-1-1-1 + // └- log: log-2-1-1-2 // traceid: 2 // └- resource: test-service-1 // └- scope: test-scope-1-1 // └- span: span-1-1-1 + // └- log: log-1-1-1-1 + // └- log: log-1-1-1-2 store := NewStore() store.maxServiceSpanCount = 1 - payload1, _ := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) - payload2, _ := test.GenerateOTLPPayload(t, 2, 1, []int{1}, [][]int{{1}}) - store.AddSpan(&payload1) - store.AddSpan(&payload2) + tp1, _ := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + tp2, _ := test.GenerateOTLPTracesPayload(t, 2, 1, []int{1}, [][]int{{1}}) + lp1, _ := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + lp2, _ := test.GenerateOTLPLogsPayload(t, 2, 1, []int{1}, [][]int{{1}}) + store.AddSpan(&tp1) + store.AddSpan(&tp2) + store.AddLog(&lp1) + store.AddLog(&lp2) before := store.updatedAt store.Flush() assert.True(t, before.Before(store.updatedAt)) + + // assert traces assert.Equal(t, 0, len(store.svcspans)) assert.Equal(t, 0, len(store.svcspansFiltered)) - assert.Equal(t, 0, len(store.cache.spanid2span)) - assert.Equal(t, 0, len(store.cache.traceid2spans)) - assert.Equal(t, 0, len(store.cache.tracesvc2spans)) + assert.Equal(t, 0, len(store.tracecache.spanid2span)) + assert.Equal(t, 0, len(store.tracecache.traceid2spans)) + assert.Equal(t, 0, len(store.tracecache.tracesvc2spans)) + + // assert logs + assert.Equal(t, 0, len(store.logs)) + assert.Equal(t, 0, len(store.logsFiltered)) + assert.Equal(t, 0, len(store.logcache.traceid2logs)) } diff --git a/tuiexporter/internal/test/loggen.go b/tuiexporter/internal/test/loggen.go new file mode 100644 index 0000000..99ccdce --- /dev/null +++ b/tuiexporter/internal/test/loggen.go @@ -0,0 +1,85 @@ +package test + +import ( + "fmt" + "testing" + "time" + + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/plog" +) + +var ( + logTimestamp = pcommon.NewTimestampFromTime(time.Date(2022, 10, 21, 7, 10, 2, 100000000, time.UTC)) + logObservedTimestamp = pcommon.NewTimestampFromTime(time.Date(2022, 10, 21, 7, 10, 2, 200000000, time.UTC)) +) + +type GeneratedLogs struct { + Logs []*plog.LogRecord + RLogs []*plog.ResourceLogs + SLogs []*plog.ScopeLogs +} + +func GenerateOTLPLogsPayload(t *testing.T, traceID, resourceCount int, scopeCount []int, spanCount [][]int) (plog.Logs, *GeneratedLogs) { + t.Helper() + + generatedLogs := &GeneratedLogs{ + Logs: []*plog.LogRecord{}, + RLogs: []*plog.ResourceLogs{}, + SLogs: []*plog.ScopeLogs{}, + } + logData := plog.NewLogs() + uniqueSpanIndex := 0 + + // Create and populate resource data + logData.ResourceLogs().EnsureCapacity(resourceCount) + for resourceIndex := 0; resourceIndex < resourceCount; resourceIndex++ { + scopeCount := scopeCount[resourceIndex] + resourceLog := logData.ResourceLogs().AppendEmpty() + fillResource(t, resourceLog.Resource(), resourceIndex) + generatedLogs.RLogs = append(generatedLogs.RLogs, &resourceLog) + + // Create and populate instrumentation scope data + resourceLog.ScopeLogs().EnsureCapacity(scopeCount) + for scopeIndex := 0; scopeIndex < scopeCount; scopeIndex++ { + spanCount := spanCount[resourceIndex][scopeIndex] + scopeLog := resourceLog.ScopeLogs().AppendEmpty() + fillScope(t, scopeLog.Scope(), resourceIndex, scopeIndex) + generatedLogs.SLogs = append(generatedLogs.SLogs, &scopeLog) + + //Create and populate spans + scopeLog.LogRecords().EnsureCapacity(spanCount) + for spanIndex := 0; spanIndex < spanCount; spanIndex++ { + // 2 logs per span + record1 := scopeLog.LogRecords().AppendEmpty() + fillLog(t, record1, traceID, resourceIndex, scopeIndex, spanIndex, 0, uniqueSpanIndex) + record2 := scopeLog.LogRecords().AppendEmpty() + fillLog(t, record2, traceID, resourceIndex, scopeIndex, spanIndex, 1, uniqueSpanIndex) + generatedLogs.Logs = append(generatedLogs.Logs, &record1, &record2) + uniqueSpanIndex++ + } + } + } + + return logData, generatedLogs +} + +func fillLog(t *testing.T, l plog.LogRecord, traceID, resourceIndex, scopeIndex, spanIndex, logIndex, uniqueSpanIndex int) { + t.Helper() + spanID := [8]byte{byte(uniqueSpanIndex + 1)} + + l.SetTraceID([16]byte{byte(traceID)}) + l.SetSpanID(spanID) + + l.Body().SetStr(fmt.Sprintf("log body %d-%d-%d-%d", resourceIndex, scopeIndex, spanIndex, logIndex)) + + l.SetSeverityNumber(plog.SeverityNumberInfo) + l.SetSeverityText("INFO") + + l.SetTimestamp(logTimestamp) + l.SetObservedTimestamp(logObservedTimestamp) + + l.SetDroppedAttributesCount(3) + l.Attributes().PutInt("span index", int64(spanIndex)) + l.SetDroppedAttributesCount(3) +} diff --git a/tuiexporter/internal/test/tracegen.go b/tuiexporter/internal/test/tracegen.go index adc9c1d..11fccb8 100644 --- a/tuiexporter/internal/test/tracegen.go +++ b/tuiexporter/internal/test/tracegen.go @@ -23,7 +23,7 @@ type GeneratedSpans struct { } // This is written referencing following code: https://github.com/CtrlSpice/otel-desktop-viewer/blob/af38ec47a37564e5f03b6d9cefa20b2422033e03/desktopexporter/testdata/trace.go -func GenerateOTLPPayload(t *testing.T, traceID, resourceCount int, scopeCount []int, spanCount [][]int) (ptrace.Traces, *GeneratedSpans) { +func GenerateOTLPTracesPayload(t *testing.T, traceID, resourceCount int, scopeCount []int, spanCount [][]int) (ptrace.Traces, *GeneratedSpans) { t.Helper() generatedSpans := &GeneratedSpans{ diff --git a/tuiexporter/internal/tui/component/log.go b/tuiexporter/internal/tui/component/log.go new file mode 100644 index 0000000..2e2a4e7 --- /dev/null +++ b/tuiexporter/internal/tui/component/log.go @@ -0,0 +1,149 @@ +package component + +import ( + "fmt" + "log" + + "github.com/rivo/tview" + "github.com/ymtdzzz/otel-tui/tuiexporter/internal/telemetry" +) + +// LogDataForTable is a wrapper for logs to be displayed in a table +type LogDataForTable struct { + tview.TableContentReadOnly + logs *[]*telemetry.LogData +} + +// NewLogDataForTable creates a new LogDataForTable. +func NewLogDataForTable(logs *[]*telemetry.LogData) LogDataForTable { + return LogDataForTable{ + logs: logs, + } +} + +// implementations for tview Virtual Table +// see: https://github.com/rivo/tview/wiki/VirtualTable +func (l LogDataForTable) GetCell(row, column int) *tview.TableCell { + if row >= 0 && row < len(*l.logs) { + return getCellFromLog((*l.logs)[row], column) + } + return tview.NewTableCell("N/A") +} + +func (l LogDataForTable) GetRowCount() int { + log.Printf("len(*l.logs): %d", len(*l.logs)) + return len(*l.logs) +} + +func (l LogDataForTable) GetColumnCount() int { + // 0: TraceID + // 1: ServiceName + // 2: Severity + // 3: RawData + return 4 +} + +// getCellFromLog returns a table cell for the given log and column. +func getCellFromLog(log *telemetry.LogData, column int) *tview.TableCell { + text := "N/A" + + switch column { + case 0: + text = log.Log.TraceID().String() + case 1: + sname, _ := log.ResourceLog.Resource().Attributes().Get("service.name") + text = sname.AsString() + case 2: + text = log.Log.SeverityText() + case 3: + text = log.Log.Body().AsString() + } + + if text == "" { + text = "N/A" + } + + return tview.NewTableCell(text) +} + +func getLogInfoTree(l *telemetry.LogData) *tview.TreeView { + if l == nil { + return nil + } + root := tview.NewTreeNode("Log") + tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root) + + // resource info + rl := l.ResourceLog + r := rl.Resource() + resource := tview.NewTreeNode("Resource") + rdropped := tview.NewTreeNode(fmt.Sprintf("dropped attributes count: %d", r.DroppedAttributesCount())) + resource.AddChild(rdropped) + rschema := tview.NewTreeNode(fmt.Sprintf("schema url: %s", rl.SchemaUrl())) + resource.AddChild(rschema) + + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, r.Attributes().AsRaw()) + resource.AddChild(attrs) + + // scope info + scopes := tview.NewTreeNode("Scopes") + sl := l.ScopeLog + s := sl.Scope() + scope := tview.NewTreeNode(s.Name()) + sschema := tview.NewTreeNode(fmt.Sprintf("schema url: %s", sl.SchemaUrl())) + scope.AddChild(sschema) + + scope.AddChild(tview.NewTreeNode(fmt.Sprintf("version: %s", s.Version()))) + scope.AddChild(tview.NewTreeNode(fmt.Sprintf("dropped attributes count: %d", s.DroppedAttributesCount()))) + + sattrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(sattrs, s.Attributes().AsRaw()) + scope.AddChild(sattrs) + + scopes.AddChild(scope) + resource.AddChild(scopes) + + // log body + record := tview.NewTreeNode("LogRecord") + + traceID := l.Log.TraceID().String() + traceNode := tview.NewTreeNode(fmt.Sprintf("trace id: %s", traceID)) + record.AddChild(traceNode) + + spanID := l.Log.SpanID().String() + spanNode := tview.NewTreeNode(fmt.Sprintf("span id: %s", spanID)) + record.AddChild(spanNode) + + timestamp := l.Log.Timestamp().AsTime().Format("2006/01/02 15:04:05.000000") + record.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", timestamp))) + + otimestamp := l.Log.ObservedTimestamp().AsTime().Format("2006/01/02 15:04:05.000000") + record.AddChild(tview.NewTreeNode(fmt.Sprintf("observed timestamp: %s", otimestamp))) + + body := tview.NewTreeNode(fmt.Sprintf("body: %s", l.Log.Body().AsString())) + record.AddChild(body) + + severity := tview.NewTreeNode(fmt.Sprintf("severity: %s (%d)", l.Log.SeverityText(), l.Log.SeverityNumber())) + record.AddChild(severity) + + flags := tview.NewTreeNode(fmt.Sprintf("flags: %d", l.Log.Flags())) + record.AddChild(flags) + + ldropped := tview.NewTreeNode(fmt.Sprintf("dropped attributes count: %d", l.Log.DroppedAttributesCount())) + record.AddChild(ldropped) + + lattrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(lattrs, l.Log.Attributes().AsRaw()) + record.AddChild(lattrs) + + resource.AddChild(record) + + root.AddChild(resource) + + tree.SetSelectedFunc(func(node *tview.TreeNode) { + node.SetExpanded(!node.IsExpanded()) + }) + + return tree +} diff --git a/tuiexporter/internal/tui/component/log_test.go b/tuiexporter/internal/tui/component/log_test.go new file mode 100644 index 0000000..26d902c --- /dev/null +++ b/tuiexporter/internal/tui/component/log_test.go @@ -0,0 +1,213 @@ +package component + +import ( + "bytes" + "fmt" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/stretchr/testify/assert" + "github.com/ymtdzzz/otel-tui/tuiexporter/internal/telemetry" + "github.com/ymtdzzz/otel-tui/tuiexporter/internal/test" +) + +func TestLogDataForTable(t *testing.T) { + // traceid: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- span: span-1-1-1 + // | | | └- log: log-1-1-1-1 + // | | | └- log: log-1-1-1-2 + // | | └- span: span-1-1-2 + // | | └- log: log-1-1-2-1 + // | | └- log: log-1-1-2-2 + // | └- scope: test-scope-1-2 + // | └- span: span-1-2-3 + // | └- log: log-1-2-3-1 + // | └- log: log-1-2-3-2 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- span: span-2-1-1 + // └- log: log-2-1-1-1 + // └- log: log-2-1-1-2 + // traceid: 2 + // └- resource: test-service-1 + // └- scope: test-scope-1-1 + // └- span: span-1-1-1 + // └- log: log-1-1-1-1 + // └- log: log-1-1-1-2 + _, testdata1 := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + _, testdata2 := test.GenerateOTLPLogsPayload(t, 2, 1, []int{1}, [][]int{{1}}) + logs := &[]*telemetry.LogData{ + { + Log: testdata1.Logs[0], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[1], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[2], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[3], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[4], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[5], + ResourceLog: testdata1.RLogs[0], + }, + { + Log: testdata1.Logs[6], + ResourceLog: testdata1.RLogs[1], + }, + { + Log: testdata1.Logs[7], + ResourceLog: testdata1.RLogs[1], + }, + { + Log: testdata2.Logs[0], + ResourceLog: testdata2.RLogs[0], + }, + { + Log: testdata2.Logs[1], + ResourceLog: testdata2.RLogs[0], + }, + } + ldftable := NewLogDataForTable(logs) + + t.Run("GetRowCount", func(t *testing.T) { + assert.Equal(t, 10, ldftable.GetRowCount()) + }) + + t.Run("GetColumnCount", func(t *testing.T) { + assert.Equal(t, 4, ldftable.GetColumnCount()) + }) + + t.Run("GetCell", func(t *testing.T) { + tests := []struct { + name string + row int + column int + want string + }{ + { + name: "invalid row", + row: 10, + column: 0, + want: "N/A", + }, + { + name: "invalid column", + row: 0, + column: 4, + want: "N/A", + }, + { + name: "trace ID trace 1 span-1-1-1", + row: 0, + column: 0, + want: "01000000000000000000000000000000", + }, + { + name: "service name trace 1 span-2-1-1", + row: 6, + column: 1, + want: "test-service-2", + }, + { + name: "serverity trace 1 span-2-1-1", + row: 6, + column: 2, + want: "INFO", + }, + { + name: "raw data trace 2 span-1-1-1", + row: 8, + column: 3, + want: "log body 0-0-0-0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, ldftable.GetCell(tt.row, tt.column).Text) + }) + } + }) +} + +func TestGetLogInfoTree(t *testing.T) { + // traceid: 1 + // └- resource: test-service-1 + // └- scope: test-scope-1-1 + // └- span: span-1-1-1 + // └- log: log-1-1-1-1 + // └- log: log-1-1-1-2 + _, testdata := test.GenerateOTLPLogsPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + logs := []*telemetry.LogData{ + { + Log: testdata.Logs[0], + ResourceLog: testdata.RLogs[0], + ScopeLog: testdata.SLogs[0], + }, + } + sw, sh := 55, 26 + screen := tcell.NewSimulationScreen("") + screen.Init() + screen.SetSize(sw, sh) + + gottree := getLogInfoTree(logs[0]) + gottree.SetRect(0, 0, sw, sh) + gottree.Draw(screen) + screen.Sync() + + contents, w, _ := screen.GetContents() + var got bytes.Buffer + for n, v := range contents { + var err error + if n%w == w-1 { + _, err = fmt.Fprintf(&got, "%c\n", v.Runes[0]) + } else { + _, err = fmt.Fprintf(&got, "%c", v.Runes[0]) + } + if err != nil { + t.Error(err) + } + } + + want := `Log +└──Resource + ├──dropped attributes count: 1 + ├──schema url: + ├──Attributes + │ ├──resource attribute: resource attribute value + │ ├──resource index: %!s(int64=0) + │ └──service.name: test-service-1 + ├──Scopes + │ └──test-scope-1-1 + │ ├──schema url: + │ ├──version: v0.0.1 + │ ├──dropped attributes count: 2 + │ └──Attributes + │ └──scope index: %!s(int64=0) + └──LogRecord + ├──trace id: 01000000000000000000000000000000 + ├──span id: 0100000000000000 + ├──timestamp: 2022/10/21 07:10:02.100000 + ├──observed timestamp: 2022/10/21 07:10:02.200000 + ├──body: log body 0-0-0-0 + ├──severity: INFO (9) + ├──flags: 0 + ├──dropped attributes count: 3 + └──Attributes + └──span index: %!s(int64=0) +` + assert.Equal(t, want, got.String()) +} diff --git a/tuiexporter/internal/tui/component/page.go b/tuiexporter/internal/tui/component/page.go index 0de3506..75942af 100644 --- a/tuiexporter/internal/tui/component/page.go +++ b/tuiexporter/internal/tui/component/page.go @@ -10,9 +10,10 @@ import ( ) const ( - PAGE_TRACES = "Traces" - PAGE_TIMELINE = "Timeline" - PAGE_LOG = "Log" + PAGE_TRACES = "Traces" + PAGE_TIMELINE = "Timeline" + PAGE_LOGS = "Logs" + PAGE_DEBUG_LOG = "DebugLog" ) type KeyMaps map[tcell.EventKey]string @@ -21,7 +22,9 @@ type TUIPages struct { pages *tview.Pages traces *tview.Flex timeline *tview.Flex - log *tview.Flex + logs *tview.Flex + debuglog *tview.Flex + current string setFocusFn func(p tview.Primitive) } @@ -29,6 +32,7 @@ func NewTUIPages(store *telemetry.Store, setFocusFn func(p tview.Primitive)) *TU pages := tview.NewPages() tp := &TUIPages{ pages: pages, + current: PAGE_TRACES, setFocusFn: setFocusFn, } @@ -45,22 +49,33 @@ func (p *TUIPages) GetPages() *tview.Pages { // ToggleLog toggles the log page. func (p *TUIPages) ToggleLog() { cname, cpage := p.pages.GetFrontPage() - if cname == PAGE_LOG { + if cname == PAGE_DEBUG_LOG { // hide log - p.pages.SendToBack(PAGE_LOG) - p.pages.HidePage(PAGE_LOG) + p.pages.SendToBack(PAGE_DEBUG_LOG) + p.pages.HidePage(PAGE_DEBUG_LOG) } else { // show log - p.pages.ShowPage(PAGE_LOG) - p.pages.SendToFront(PAGE_LOG) + p.pages.ShowPage(PAGE_DEBUG_LOG) + p.pages.SendToFront(PAGE_DEBUG_LOG) p.setFocusFn(cpage) } } +// TogglePage toggles Traces & Logs page. +func (p *TUIPages) TogglePage() { + if p.current == PAGE_TRACES { + p.pages.SwitchToPage(PAGE_LOGS) + p.current = PAGE_LOGS + } else { + p.pages.SwitchToPage(PAGE_TRACES) + p.current = PAGE_TRACES + } +} + func (p *TUIPages) registerPages(store *telemetry.Store) { - logpage := p.createLogPage() - p.log = logpage - p.pages.AddPage(PAGE_LOG, logpage, true, true) + logpage := p.createDebugLogPage() + p.debuglog = logpage + p.pages.AddPage(PAGE_DEBUG_LOG, logpage, true, true) traces := p.createTracePage(store) p.traces = traces @@ -69,6 +84,10 @@ func (p *TUIPages) registerPages(store *telemetry.Store) { timeline := p.createTimelinePage() p.timeline = timeline p.pages.AddPage(PAGE_TIMELINE, timeline, true, false) + + logs := p.createLogPage(store) + p.logs = logs + p.pages.AddPage(PAGE_LOGS, logs, true, false) } func (p *TUIPages) createTracePage(store *telemetry.Store) *tview.Flex { @@ -134,17 +153,15 @@ func (p *TUIPages) createTracePage(store *telemetry.Store) *tview.Flex { page.AddItem(tableContainer, 0, 6, true).AddItem(details, 0, 4, false) page.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { - case 'd': - if !search.HasFocus() { + if !search.HasFocus() { + switch event.Rune() { + case 'd': p.setFocusFn(details) - } - // don't return nil here, because we want to pass the event to the search input - case 't': - if !search.HasFocus() { + // don't return nil here, because we want to pass the event to the search input + case 't': p.setFocusFn(table) + // don't return nil here, because we want to pass the event to the search input } - // don't return nil here, because we want to pass the event to the search input } if event.Key() == tcell.KeyCtrlL { @@ -161,6 +178,7 @@ func (p *TUIPages) createTracePage(store *telemetry.Store) *tview.Flex { *tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone): "(search) Confirm", *tcell.NewEventKey(tcell.KeyCtrlL, ' ', tcell.ModNone): "Clear all data", }) + page = attatchTab(page, PAGE_TRACES) return page } @@ -205,7 +223,108 @@ func (p *TUIPages) refreshTimeline(store *telemetry.Store, row int) { p.timeline.AddItem(timeline, 0, 1, true) } -func (p *TUIPages) createLogPage() *tview.Flex { +func (p *TUIPages) createLogPage(store *telemetry.Store) *tview.Flex { + pageContainer := tview.NewFlex().SetDirection(tview.FlexRow) + page := tview.NewFlex().SetDirection(tview.FlexColumn) + + details := tview.NewFlex().SetDirection(tview.FlexRow) + details.SetTitle("Details (d)").SetBorder(true) + + body := tview.NewTextView() + body.SetBorder(true).SetTitle("Body (b)") + + tableContainer := tview.NewFlex().SetDirection(tview.FlexRow) + tableContainer.SetTitle("Logs (o)").SetBorder(true) + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false). + SetContent(NewLogDataForTable(store.GetFilteredLogs())) + + input := "" + inputConfirmed := "" + search := tview.NewInputField(). + SetLabel("Filter by service or body (/): "). + SetFieldWidth(20) + search.SetChangedFunc(func(text string) { + // remove the suffix '/' from input because it is passed from SetInputCapture() + if strings.HasSuffix(text, "/") { + text = strings.TrimSuffix(text, "/") + search.SetText(text) + } + input = text + }) + search.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEnter { + inputConfirmed = input + store.ApplyFilterLogs(inputConfirmed) + } else if key == tcell.KeyEsc { + search.SetText(inputConfirmed) + } + p.setFocusFn(table) + }) + + table.SetSelectionChangedFunc(func(row, _ int) { + selected := store.GetFilteredLogByIdx(row) + details.Clear() + details.AddItem(getLogInfoTree(selected), 0, 1, true) + log.Printf("selected row: %d", row) + + if selected != nil { + body.SetText(selected.Log.Body().AsString()) + } + }) + tableContainer. + AddItem(search, 1, 0, false). + AddItem(table, 0, 1, true) + + tableContainer.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Rune() == '/' { + if !search.HasFocus() { + p.setFocusFn(search) + } + return nil + } + + return event + }) + + page.AddItem(tableContainer, 0, 6, true).AddItem(details, 0, 4, false) + pageContainer.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if !search.HasFocus() { + switch event.Rune() { + case 'd': + p.setFocusFn(details) + // don't return nil here, because we want to pass the event to the search input + case 'o': + p.setFocusFn(table) + // don't return nil here, because we want to pass the event to the search input + case 'b': + p.setFocusFn(body) + // don't return nil here, because we want to pass the event to the search input + } + } + + if event.Key() == tcell.KeyCtrlL { + store.Flush() + return nil + } + + return event + }) + pageContainer.AddItem(page, 0, 1, true).AddItem(body, 5, 1, false) + pageContainer = attatchCommandList(pageContainer, KeyMaps{ + *tcell.NewEventKey(tcell.KeyF12, ' ', tcell.ModNone): "Toggle Log", + *tcell.NewEventKey(tcell.KeyRune, '/', tcell.ModNone): "Search logs", + *tcell.NewEventKey(tcell.KeyEsc, ' ', tcell.ModNone): "(search) Cancel", + *tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone): "(search) Confirm", + *tcell.NewEventKey(tcell.KeyCtrlL, ' ', tcell.ModNone): "Clear all data", + }) + pageContainer = attatchTab(pageContainer, PAGE_LOGS) + + return pageContainer +} + +func (p *TUIPages) createDebugLogPage() *tview.Flex { logview := tview.NewTextView().SetDynamicColors(true) logview.Box.SetTitle("Log").SetBorder(true) log.SetOutput(logview) @@ -217,6 +336,27 @@ func (p *TUIPages) createLogPage() *tview.Flex { return page } +func attatchTab(p tview.Primitive, name string) *tview.Flex { + var text string + switch name { + case PAGE_TRACES: + text = "< [yellow]Traces[white] | Logs > (Tab to switch)" + case PAGE_LOGS: + text = "< Traces | [yellow]Logs[white] > (Tab to switch)" + } + + tabs := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter). + SetText(text) + + base := tview.NewFlex().SetDirection(tview.FlexRow) + base.AddItem(tabs, 1, 1, false). + AddItem(p, 0, 1, true) + + return base +} + func attatchCommandList(p tview.Primitive, keys KeyMaps) *tview.Flex { keytexts := []string{} for k, v := range keys { diff --git a/tuiexporter/internal/tui/component/timeline_test.go b/tuiexporter/internal/tui/component/timeline_test.go index 1e6c259..0a8b221 100644 --- a/tuiexporter/internal/tui/component/timeline_test.go +++ b/tuiexporter/internal/tui/component/timeline_test.go @@ -20,7 +20,7 @@ func TestNewSpanTree(t *testing.T) { // └- span: span-1-1-5 [root] multiple root span is allowed // └- span: span-1-1-6 store := telemetry.NewStore() - payload, testdata := test.GenerateOTLPPayload(t, 1, 1, []int{1}, [][]int{{6}}) + payload, testdata := test.GenerateOTLPTracesPayload(t, 1, 1, []int{1}, [][]int{{6}}) sds := []*telemetry.SpanData{} for _, span := range testdata.Spans { sds = append(sds, &telemetry.SpanData{ diff --git a/tuiexporter/internal/tui/component/trace_test.go b/tuiexporter/internal/tui/component/trace_test.go index a5f52d8..4211fe9 100644 --- a/tuiexporter/internal/tui/component/trace_test.go +++ b/tuiexporter/internal/tui/component/trace_test.go @@ -12,7 +12,7 @@ import ( "github.com/ymtdzzz/otel-tui/tuiexporter/internal/test" ) -func TestSpanForTable(t *testing.T) { +func TestSpanDataForTable(t *testing.T) { // traceid: 1 // └- resource: test-service-1 // | └- scope: test-scope-1-1 @@ -27,8 +27,8 @@ func TestSpanForTable(t *testing.T) { // └- resource: test-service-1 // └- scope: test-scope-1-1 // └- span: span-1-1-1 - _, testdata1 := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) - _, testdata2 := test.GenerateOTLPPayload(t, 2, 1, []int{1}, [][]int{{1}}) + _, testdata1 := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + _, testdata2 := test.GenerateOTLPTracesPayload(t, 2, 1, []int{1}, [][]int{{1}}) receivedAt := time.Date(2024, 3, 30, 12, 30, 15, 0, time.UTC) svcspans := &telemetry.SvcSpans{ &telemetry.SpanData{ @@ -115,7 +115,7 @@ func TestGetTraceInfoTree(t *testing.T) { // └- resource: test-service-2 // └- scope: test-scope-2-1 // └- span: span-2-1-1 - _, testdata := test.GenerateOTLPPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + _, testdata := test.GenerateOTLPTracesPayload(t, 1, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) spans := []*telemetry.SpanData{} spans = append(spans, &telemetry.SpanData{ Span: testdata.Spans[0], diff --git a/tuiexporter/internal/tui/tui.go b/tuiexporter/internal/tui/tui.go index 4e8824c..5356941 100644 --- a/tuiexporter/internal/tui/tui.go +++ b/tuiexporter/internal/tui/tui.go @@ -35,9 +35,12 @@ func NewTUIApp(store *telemetry.Store) *TUIApp { app.SetRoot(pages, true) app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyF12 { + switch event.Key() { + case tcell.KeyF12: tpages.ToggleLog() - + return nil + case tcell.KeyTab: + tpages.TogglePage() return nil } return event