diff --git a/go.mod b/go.mod index 4a29d5b..71199be 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mostynb/go-grpc-compression v1.2.3 // indirect + github.com/navidys/tvxwidgets v0.6.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_golang v1.19.1 // indirect diff --git a/go.sum b/go.sum index 4cdd405..7aadbce 100644 --- a/go.sum +++ b/go.sum @@ -104,6 +104,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mostynb/go-grpc-compression v1.2.3 h1:42/BKWMy0KEJGSdWvzqIyOZ95YcR9mLPqKctH7Uo//I= github.com/mostynb/go-grpc-compression v1.2.3/go.mod h1:AghIxF3P57umzqM9yz795+y1Vjs47Km/Y2FE6ouQ7Lg= +github.com/navidys/tvxwidgets v0.6.0 h1:ARIXGfx4aURHMhq+LW5vIoCCDx1X/PdTF8AcUq+nWZ0= +github.com/navidys/tvxwidgets v0.6.0/go.mod h1:wd6aS2OzjZczFbg8GCaVuwkFcY1eixlT/y7Lc/YIwlg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= diff --git a/go.work.sum b/go.work.sum index f1459b7..0ed7349 100644 --- a/go.work.sum +++ b/go.work.sum @@ -132,6 +132,7 @@ github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBj github.com/go-latex/latex v0.0.0-20231108140139-5c1ce85aa4ea/go.mod h1:Y7Vld91/HRbTBm7JwoI7HejdDB0u+e9AUBO9MB7yuZk= github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/goccmack/gocc v0.0.0-20230228185258-2292f9e40198/go.mod h1:DTh/Y2+NbnOVVoypCCQrovMPDKUGp4yZpSbWg5D0XIM= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -139,10 +140,13 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/onsi/ginkgo/v2 v2.16.0/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.31.1/go.mod h1:y40C95dwAD1Nz36SsEnxvfFe8FFfNxzI5eJ0EYGyAy0= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= diff --git a/main.go b/main.go index 008e873..23331af 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,10 @@ service: receivers: [otlp] processors: [] exporters: [tui] + metrics: + receivers: [otlp] + processors: [] + exporters: [tui] ` configProviderSettings := otelcol.ConfigProviderSettings{ diff --git a/tuiexporter/exporter.go b/tuiexporter/exporter.go index 76b9d44..01002ef 100644 --- a/tuiexporter/exporter.go +++ b/tuiexporter/exporter.go @@ -8,6 +8,7 @@ import ( "github.com/ymtdzzz/otel-tui/tuiexporter/internal/tui" "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/pdata/pmetric" "go.opentelemetry.io/collector/pdata/ptrace" ) @@ -27,6 +28,12 @@ func (e *tuiExporter) pushTraces(_ context.Context, traces ptrace.Traces) error return nil } +func (e *tuiExporter) pushMetrics(_ context.Context, metrics pmetric.Metrics) error { + e.app.Store().AddMetric(&metrics) + + return nil +} + func (e *tuiExporter) pushLogs(_ context.Context, logs plog.Logs) error { e.app.Store().AddLog(&logs) diff --git a/tuiexporter/factory.go b/tuiexporter/factory.go index 03c4523..36a9ee0 100644 --- a/tuiexporter/factory.go +++ b/tuiexporter/factory.go @@ -19,7 +19,7 @@ func NewFactory() exporter.Factory { component.MustNewType("tui"), createDefaultConfig, exporter.WithTraces(createTraces, stability), - //exporter.WithMetrics(createMetrics, stability), + exporter.WithMetrics(createMetrics, stability), exporter.WithLogs(createLogs, stability), ) } @@ -49,6 +49,27 @@ func createTraces(ctx context.Context, set exporter.Settings, cfg component.Conf ) } +func createMetrics(ctx context.Context, set exporter.Settings, cfg component.Config) (exporter.Metrics, 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.NewMetricsExporter(ctx, set, oCfg, + e.Unwrap().pushMetrics, + exporterhelper.WithStart(e.Start), + exporterhelper.WithShutdown(e.Shutdown), + ) +} + func createLogs(ctx context.Context, set exporter.Settings, cfg component.Config) (exporter.Logs, error) { oCfg := cfg.(*Config) diff --git a/tuiexporter/internal/telemetry/cache.go b/tuiexporter/internal/telemetry/cache.go index 0934cc9..2cf3e09 100644 --- a/tuiexporter/internal/telemetry/cache.go +++ b/tuiexporter/internal/telemetry/cache.go @@ -12,10 +12,6 @@ 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 @@ -103,6 +99,10 @@ func (c *TraceCache) flush() { c.tracesvc2spans = TraceServiceSpanDataMap{} } +// 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 + // LogCache is a cache of logs type LogCache struct { traceid2logs TraceLogDataMap @@ -149,3 +149,71 @@ func (c *LogCache) GetLogsByTraceID(traceID string) ([]*LogData, bool) { func (c *LogCache) flush() { c.traceid2logs = TraceLogDataMap{} } + +// MetricServiceMetricDataMap is a map of service name and metric name to a slice of metrics +// This is used to quickly look up datapoints in a service metric +type MetricServiceMetricDataMap map[string]map[string][]*MetricData + +// MetricCache is a cache of metrics +type MetricCache struct { + svcmetric2metrics MetricServiceMetricDataMap +} + +// NewMetricCache returns a new metric cache +func NewMetricCache() *MetricCache { + return &MetricCache{ + svcmetric2metrics: MetricServiceMetricDataMap{}, + } +} + +// UpdateCache updates the cache with a new metric +func (c *MetricCache) UpdateCache(sname string, data *MetricData) { + mname := data.Metric.Name() + if sms, ok := c.svcmetric2metrics[sname]; ok { + if _, ok := sms[mname]; ok { + c.svcmetric2metrics[sname][mname] = append(c.svcmetric2metrics[sname][mname], data) + } else { + c.svcmetric2metrics[sname][mname] = []*MetricData{data} + } + } else { + c.svcmetric2metrics[sname] = map[string][]*MetricData{mname: {data}} + } +} + +// DeleteCache deletes a list of metrics from the cache +func (c *MetricCache) DeleteCache(metrics []*MetricData) { + for _, m := range metrics { + sname := "N/A" + if snameattr, ok := m.ResourceMetric.Resource().Attributes().Get("service.name"); ok { + sname = snameattr.AsString() + } + mname := m.Metric.Name() + if _, ok := c.svcmetric2metrics[sname][mname]; ok { + for i, metric := range c.svcmetric2metrics[sname][mname] { + if metric == m { + c.svcmetric2metrics[sname][mname] = append(c.svcmetric2metrics[sname][mname][:i], c.svcmetric2metrics[sname][mname][i+1:]...) + if len(c.svcmetric2metrics[sname][mname]) == 0 { + delete(c.svcmetric2metrics[sname], mname) + if len(c.svcmetric2metrics[sname]) == 0 { + delete(c.svcmetric2metrics, sname) + } + } + } + } + } + } +} + +// GetMetricsBySvcAndMetricName returns all metrics for a given service name and metric name +func (c *MetricCache) GetMetricsBySvcAndMetricName(sname, mname string) ([]*MetricData, bool) { + if sms, ok := c.svcmetric2metrics[sname]; ok { + if ms, ok := sms[mname]; ok { + return ms, ok + } + } + return nil, false +} + +func (c *MetricCache) flush() { + c.svcmetric2metrics = MetricServiceMetricDataMap{} +} diff --git a/tuiexporter/internal/telemetry/cache_test.go b/tuiexporter/internal/telemetry/cache_test.go index 7b925ce..e58fde7 100644 --- a/tuiexporter/internal/telemetry/cache_test.go +++ b/tuiexporter/internal/telemetry/cache_test.go @@ -151,3 +151,47 @@ func TestGetLogsByTraceID(t *testing.T) { }) } } + +func TestGetMetricsBySvcAndMetricName(t *testing.T) { + c := NewMetricCache() + metrics := []*MetricData{} + c.svcmetric2metrics["sname"] = map[string][]*MetricData{"mname": metrics} + + tests := []struct { + name string + sname string + mname string + wantdata []*MetricData + wantok bool + }{ + { + name: "service and metrics exists", + sname: "sname", + mname: "mname", + wantdata: metrics, + wantok: true, + }, + { + name: "service exists but metrics does not", + sname: "sname", + mname: "non-existent-metric", + wantdata: nil, + wantok: false, + }, + { + name: "service does not exist", + sname: "non-existent-sname", + mname: "mname", + wantdata: nil, + wantok: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotdata, gotok := c.GetMetricsBySvcAndMetricName(tt.sname, tt.mname) + 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 855d361..dca6dae 100644 --- a/tuiexporter/internal/telemetry/store.go +++ b/tuiexporter/internal/telemetry/store.go @@ -7,11 +7,13 @@ import ( "go.opentelemetry.io/collector/pdata/pcommon" "go.opentelemetry.io/collector/pdata/plog" + "go.opentelemetry.io/collector/pdata/pmetric" "go.opentelemetry.io/collector/pdata/ptrace" ) const ( MAX_SERVICE_SPAN_COUNT = 1000 + MAX_METRIC_COUNT = 1000 MAX_LOG_COUNT = 1000 ) @@ -32,6 +34,14 @@ func (sd *SpanData) IsRoot() bool { // This is a slice of one span of a single service type SvcSpans []*SpanData +// MetricData is a struct to represent a metric +type MetricData struct { + Metric *pmetric.Metric + ResourceMetric *pmetric.ResourceMetrics + ScopeMetric *pmetric.ScopeMetrics + ReceivedAt time.Time +} + // LogData is a struct to represent a log type LogData struct { Log *plog.LogRecord @@ -54,15 +64,20 @@ func (l *LogData) GetResolvedBody() string { type Store struct { mut sync.Mutex filterSvc string + filterMetric string filterLog string svcspans SvcSpans svcspansFiltered SvcSpans tracecache *TraceCache + metrics []*MetricData + metricsFiltered []*MetricData + metriccache *MetricCache logs []*LogData logsFiltered []*LogData logcache *LogCache updatedAt time.Time maxServiceSpanCount int + maxMetricCount int maxLogCount int } @@ -73,19 +88,28 @@ func NewStore() *Store { svcspans: SvcSpans{}, svcspansFiltered: SvcSpans{}, tracecache: NewTraceCache(), + metrics: []*MetricData{}, + metricsFiltered: []*MetricData{}, + metriccache: NewMetricCache(), logs: []*LogData{}, logsFiltered: []*LogData{}, logcache: NewLogCache(), maxServiceSpanCount: MAX_SERVICE_SPAN_COUNT, // TODO: make this configurable + maxMetricCount: MAX_METRIC_COUNT, // TODO: make this configurable maxLogCount: MAX_LOG_COUNT, // TODO: make this configurable } } -// GetTraceCache returns the cache +// GetTraceCache returns the trace cache func (s *Store) GetTraceCache() *TraceCache { return s.tracecache } +// GetMetricCache returns the metric cache +func (s *Store) GetMetricCache() *MetricCache { + return s.metriccache +} + // GetLogCache returns the log cache func (s *Store) GetLogCache() *LogCache { return s.logcache @@ -101,6 +125,11 @@ func (s *Store) GetFilteredSvcSpans() *SvcSpans { return &s.svcspansFiltered } +// GetFilteredMetrics returns the filetered metrics in the store +func (s *Store) GetFilteredMetrics() *[]*MetricData { + return &s.metricsFiltered +} + // GetFilteredLogs returns the filtered logs in the store func (s *Store) GetFilteredLogs() *[]*LogData { return &s.logsFiltered @@ -133,6 +162,32 @@ func (s *Store) updateFilterService() { s.ApplyFilterService(s.filterSvc) } +// ApplyFilterMetrics applies a filter to the metrics +func (s *Store) ApplyFilterMetrics(filter string) { + s.filterMetric = filter + s.metricsFiltered = []*MetricData{} + + if filter == "" { + s.metricsFiltered = s.metrics + return + } + + for _, metric := range s.metrics { + sname := "" + if snameattr, ok := metric.ResourceMetric.Resource().Attributes().Get("service.name"); ok { + sname = snameattr.AsString() + } + target := sname + " " + metric.Metric.Name() + if strings.Contains(target, filter) { + s.metricsFiltered = append(s.metricsFiltered, metric) + } + } +} + +func (s *Store) updateFilterMetrics() { + s.ApplyFilterMetrics(s.filterMetric) +} + // ApplyFilterLogs applies a filter to the logs func (s *Store) ApplyFilterLogs(filter string) { s.filterLog = filter @@ -144,8 +199,11 @@ func (s *Store) ApplyFilterLogs(filter string) { } for _, log := range s.logs { - sname, _ := log.ResourceLog.Resource().Attributes().Get("service.name") - target := sname.AsString() + " " + log.Log.Body().AsString() + sname := "" + if snameattr, ok := log.ResourceLog.Resource().Attributes().Get("service.name"); ok { + sname = snameattr.AsString() + } + target := sname + " " + log.Log.Body().AsString() if strings.Contains(target, filter) { s.logsFiltered = append(s.logsFiltered, log) } @@ -177,6 +235,14 @@ func (s *Store) GetFilteredServiceSpansByIdx(idx int) []*SpanData { return spans } +// GetFilteredMetricByIdx returns the metric at the given index +func (s *Store) GetFilteredMetricByIdx(idx int) *MetricData { + if idx < 0 || idx >= len(s.metricsFiltered) { + return nil + } + return s.metricsFiltered[idx] +} + // GetFilteredLogByIdx returns the log at the given index func (s *Store) GetFilteredLogByIdx(idx int) *LogData { if idx < 0 || idx >= len(s.logsFiltered) { @@ -185,7 +251,7 @@ func (s *Store) GetFilteredLogByIdx(idx int) *LogData { return s.logsFiltered[idx] } -// AddSpan adds a span to the store +// AddSpan adds spans to the store func (s *Store) AddSpan(traces *ptrace.Traces) { s.mut.Lock() defer func() { @@ -203,6 +269,7 @@ func (s *Store) AddSpan(traces *ptrace.Traces) { span := ss.Spans().At(si) // attribute service.name is required // see: https://opentelemetry.io/docs/specs/semconv/resource/#service + // TODO: set default value when service name is not set sname, _ := rs.Resource().Attributes().Get("service.name") sd := &SpanData{ Span: &span, @@ -230,7 +297,50 @@ func (s *Store) AddSpan(traces *ptrace.Traces) { s.updateFilterService() } -// AddLog adds a log to the store +// AddMetric adds metrics to the store +func (s *Store) AddMetric(metrics *pmetric.Metrics) { + s.mut.Lock() + defer func() { + s.updatedAt = time.Now() + s.mut.Unlock() + }() + + for rmi := 0; rmi < metrics.ResourceMetrics().Len(); rmi++ { + rm := metrics.ResourceMetrics().At(rmi) + + for smi := 0; smi < rm.ScopeMetrics().Len(); smi++ { + sm := rm.ScopeMetrics().At(smi) + + for si := 0; si < sm.Metrics().Len(); si++ { + sname := "N/A" + if snameattr, ok := rm.Resource().Attributes().Get("service.name"); ok { + sname = snameattr.AsString() + } + metric := sm.Metrics().At(si) + sd := &MetricData{ + Metric: &metric, + ResourceMetric: &rm, + ScopeMetric: &sm, + ReceivedAt: time.Now(), + } + s.metrics = append(s.metrics, sd) + s.metriccache.UpdateCache(sname, sd) + } + } + } + + // data rotation + if len(s.metrics) > s.maxMetricCount { + deleteMetrics := s.metrics[:len(s.metrics)-s.maxMetricCount] + s.metrics = s.metrics[len(s.metrics)-s.maxMetricCount:] + + s.metriccache.DeleteCache(deleteMetrics) + } + + s.updateFilterMetrics() +} + +// AddLog adds logs to the store func (s *Store) AddLog(logs *plog.Logs) { s.mut.Lock() defer func() { @@ -280,6 +390,9 @@ func (s *Store) Flush() { s.svcspans = SvcSpans{} s.svcspansFiltered = SvcSpans{} s.tracecache.flush() + s.metrics = []*MetricData{} + s.metricsFiltered = []*MetricData{} + s.metriccache.flush() s.logs = []*LogData{} s.logsFiltered = []*LogData{} s.logcache.flush() diff --git a/tuiexporter/internal/telemetry/store_test.go b/tuiexporter/internal/telemetry/store_test.go index e7fc173..0448616 100644 --- a/tuiexporter/internal/telemetry/store_test.go +++ b/tuiexporter/internal/telemetry/store_test.go @@ -43,8 +43,11 @@ func TestSpanDataIsRoot(t *testing.T) { func TestStoreGetter(t *testing.T) { store := NewStore() assert.Equal(t, store.tracecache, store.GetTraceCache()) + assert.Equal(t, store.metriccache, store.GetMetricCache()) + assert.Equal(t, store.logcache, store.GetLogCache()) assert.Equal(t, &store.svcspans, store.GetSvcSpans()) assert.Equal(t, &store.svcspansFiltered, store.GetFilteredSvcSpans()) + assert.Equal(t, &store.metricsFiltered, store.GetFilteredMetrics()) assert.Equal(t, &store.logsFiltered, store.GetFilteredLogs()) assert.Equal(t, store.updatedAt, store.UpdatedAt()) } @@ -106,6 +109,64 @@ func TestStoreSpanFilters(t *testing.T) { } } +func TestStoreMetricFilters(t *testing.T) { + // metric: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- metric: metric-1-1-1 + // | | └- datapoint: dp-1-1-1-1 + // | | └- datapoint: dp-1-1-1-2 + // | └- scope: test-scope-1-2 + // | └- metric: metric-1-2-1 + // | └- datapoint: dp-1-2-1-1 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- metric: metric-2-1-1 + // └- datapoint: dp-2-1-1-1 + store := NewStore() + payload, testdata := test.GenerateOTLPMetricsPayload(t, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddMetric(&payload) + + store.ApplyFilterMetrics("service-2") + assert.Equal(t, 1, len(store.metricsFiltered)) + store.ApplyFilterMetrics("metric 0") + assert.Equal(t, 2, len(store.metricsFiltered)) + + tests := []struct { + name string + idx int + want *MetricData + }{ + { + name: "invalid index", + idx: 2, + want: nil, + }, + { + name: "valid index", + idx: 1, + want: &MetricData{ + Metric: testdata.Metrics[1], // metric-1-2-1 + ResourceMetric: testdata.RMetrics[0], // test-service-1 + ScopeMetric: testdata.SMetrics[1], // test-scope-1-2 + }, + }, + } + + for _, tt := range tests { + t.Run("GetFilteredMetricByIdx_"+tt.name, func(t *testing.T) { + got := store.GetFilteredMetricByIdx(tt.idx) + if tt.want != nil { + assert.Equal(t, tt.want.Metric, got.Metric) + assert.Equal(t, tt.want.ResourceMetric, got.ResourceMetric) + assert.Equal(t, tt.want.ScopeMetric, got.ScopeMetric) + } else { + assert.Nil(t, got) + } + }) + } +} + func TestStoreLogFilters(t *testing.T) { // traceid: 1 // └- resource: test-service-1 @@ -290,6 +351,89 @@ func TestStoreAddSpanWithRotation(t *testing.T) { } } +func TestStoreAddMetricWithoutRotation(t *testing.T) { + // metric: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- metric: metric-1-1-1 + // | | └- datapoint: dp-1-1-1-1 + // | | └- datapoint: dp-1-1-1-2 + // | └- scope: test-scope-1-2 + // | └- metric: metric-1-2-1 + // | └- datapoint: dp-1-2-1-1 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- metric: metric-2-1-1 + // └- datapoint: dp-2-1-1-1 + store := NewStore() + store.maxMetricCount = 3 // no rotation + before := store.updatedAt + payload, testdata := test.GenerateOTLPMetricsPayload(t, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddMetric(&payload) + + assert.Equal(t, "", store.filterMetric) + assert.True(t, before.Before(store.updatedAt)) + + // assert metrics + assert.Equal(t, 3, len(store.metrics)) + assert.Equal(t, testdata.Metrics[0], store.metrics[0].Metric) // metric-1-1-1 + assert.Equal(t, testdata.RMetrics[0], store.metrics[0].ResourceMetric) // test-service-1 + assert.Equal(t, testdata.SMetrics[0], store.metrics[0].ScopeMetric) // test-scope-1-1 + assert.Equal(t, testdata.Metrics[2], store.metrics[2].Metric) // metric-2-1-1 + assert.Equal(t, testdata.RMetrics[1], store.metrics[2].ResourceMetric) // test-service-2 + assert.Equal(t, testdata.SMetrics[2], store.metrics[2].ScopeMetric) // test-scope-2-1 + + // assert metricsFiltered + assert.Equal(t, 3, len(store.metricsFiltered)) + assert.Equal(t, testdata.Metrics[0], store.metricsFiltered[0].Metric) // metric-1-1-1 + assert.Equal(t, testdata.Metrics[2], store.metricsFiltered[2].Metric) // metric-2-1-1 + + // assert cache svcmetric2metrics + assert.Equal(t, 2, len(store.metriccache.svcmetric2metrics)) + assert.Equal(t, 2, len(store.metriccache.svcmetric2metrics["test-service-1"])) // metric-1-1-1, metric-1-2-1 + assert.Equal(t, 1, len(store.metriccache.svcmetric2metrics["test-service-2"])) // metric-2-1-1 + assert.Equal(t, testdata.Metrics[1], store.metriccache.svcmetric2metrics["test-service-1"]["metric 0-1"][0].Metric) +} + +func TestStoreAddMetricWithRotation(t *testing.T) { + // metric: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- metric: metric-1-1-1 + // | | └- datapoint: dp-1-1-1-1 + // | | └- datapoint: dp-1-1-1-2 + // | └- scope: test-scope-1-2 + // | └- metric: metric-1-2-1 + // | └- datapoint: dp-1-2-1-1 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- metric: metric-2-1-1 + // └- datapoint: dp-2-1-1-1 + store := NewStore() + store.maxMetricCount = 1 // no rotation + before := store.updatedAt + payload, testdata := test.GenerateOTLPMetricsPayload(t, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) + store.AddMetric(&payload) + + assert.Equal(t, "", store.filterMetric) + assert.True(t, before.Before(store.updatedAt)) + + // assert metrics + assert.Equal(t, 1, len(store.metrics)) + assert.Equal(t, testdata.Metrics[2], store.metrics[0].Metric) // metric-2-1-1 + assert.Equal(t, testdata.RMetrics[1], store.metrics[0].ResourceMetric) // test-service-2 + assert.Equal(t, testdata.SMetrics[2], store.metrics[0].ScopeMetric) // test-scope-2-1 + + // assert metricsFiltered + assert.Equal(t, 1, len(store.metricsFiltered)) + assert.Equal(t, testdata.Metrics[2], store.metricsFiltered[0].Metric) // metric-2-1-1 + + // assert cache svcmetric2metrics + assert.Equal(t, 1, len(store.metriccache.svcmetric2metrics)) + assert.Equal(t, 1, len(store.metriccache.svcmetric2metrics["test-service-2"])) // metric-2-1-1 + assert.Equal(t, testdata.Metrics[2], store.metriccache.svcmetric2metrics["test-service-2"]["metric 1-0"][0].Metric) +} + func TestStoreAddLogWithoutRotation(t *testing.T) { // traceid: 1 // └- resource: test-service-1 @@ -413,16 +557,31 @@ func TestStoreFlush(t *testing.T) { // └- span: span-1-1-1 // └- log: log-1-1-1-1 // └- log: log-1-1-1-2 + // metric: 1 + // └- resource: test-service-1 + // | └- scope: test-scope-1-1 + // | | └- metric: metric-1-1-1 + // | | └- datapoint: dp-1-1-1-1 + // | | └- datapoint: dp-1-1-1-2 + // | └- scope: test-scope-1-2 + // | └- metric: metric-1-2-1 + // | └- datapoint: dp-1-2-1-1 + // └- resource: test-service-2 + // └- scope: test-scope-2-1 + // └- metric: metric-2-1-1 + // └- datapoint: dp-2-1-1-1 store := NewStore() store.maxServiceSpanCount = 1 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}}) + m, _ := test.GenerateOTLPMetricsPayload(t, 2, []int{2, 1}, [][]int{{2, 1}, {1}}) store.AddSpan(&tp1) store.AddSpan(&tp2) store.AddLog(&lp1) store.AddLog(&lp2) + store.AddMetric(&m) before := store.updatedAt store.Flush() @@ -440,6 +599,11 @@ func TestStoreFlush(t *testing.T) { assert.Equal(t, 0, len(store.logs)) assert.Equal(t, 0, len(store.logsFiltered)) assert.Equal(t, 0, len(store.logcache.traceid2logs)) + + // assert metrics + assert.Equal(t, 0, len(store.metrics)) + assert.Equal(t, 0, len(store.metricsFiltered)) + assert.Equal(t, 0, len(store.metriccache.svcmetric2metrics)) } func TestLogDataGetResolvedBody(t *testing.T) { diff --git a/tuiexporter/internal/test/loggen.go b/tuiexporter/internal/test/loggen.go index 99ccdce..2a8ead8 100644 --- a/tuiexporter/internal/test/loggen.go +++ b/tuiexporter/internal/test/loggen.go @@ -47,7 +47,7 @@ func GenerateOTLPLogsPayload(t *testing.T, traceID, resourceCount int, scopeCoun fillScope(t, scopeLog.Scope(), resourceIndex, scopeIndex) generatedLogs.SLogs = append(generatedLogs.SLogs, &scopeLog) - //Create and populate spans + // Create and populate logs scopeLog.LogRecords().EnsureCapacity(spanCount) for spanIndex := 0; spanIndex < spanCount; spanIndex++ { // 2 logs per span diff --git a/tuiexporter/internal/test/metricgen.go b/tuiexporter/internal/test/metricgen.go new file mode 100644 index 0000000..19fe3ad --- /dev/null +++ b/tuiexporter/internal/test/metricgen.go @@ -0,0 +1,75 @@ +package test + +import ( + "fmt" + "testing" + + "go.opentelemetry.io/collector/pdata/pmetric" +) + +type GeneratedMetrics struct { + Metrics []*pmetric.Metric + RMetrics []*pmetric.ResourceMetrics + SMetrics []*pmetric.ScopeMetrics +} + +func GenerateOTLPMetricsPayload(t *testing.T, resourceCount int, scopeCount []int, dpCount [][]int) (pmetric.Metrics, *GeneratedMetrics) { + t.Helper() + + generatedMetrics := &GeneratedMetrics{ + Metrics: []*pmetric.Metric{}, + RMetrics: []*pmetric.ResourceMetrics{}, + SMetrics: []*pmetric.ScopeMetrics{}, + } + metricData := pmetric.NewMetrics() + + // Create and populate resource data + metricData.ResourceMetrics().EnsureCapacity(resourceCount) + for resourceIndex := 0; resourceIndex < resourceCount; resourceIndex++ { + scopeCount := scopeCount[resourceIndex] + resourceMetric := metricData.ResourceMetrics().AppendEmpty() + fillResource(t, resourceMetric.Resource(), resourceIndex) + generatedMetrics.RMetrics = append(generatedMetrics.RMetrics, &resourceMetric) + + // Create and populate instrumentation scope data + resourceMetric.ScopeMetrics().EnsureCapacity(scopeCount) + for scopeIndex := 0; scopeIndex < scopeCount; scopeIndex++ { + scopeMetric := resourceMetric.ScopeMetrics().AppendEmpty() + fillScope(t, scopeMetric.Scope(), resourceIndex, scopeIndex) + generatedMetrics.SMetrics = append(generatedMetrics.SMetrics, &scopeMetric) + + // Create and populate metrics + // 1 metric per scope + scopeMetric.Metrics().EnsureCapacity(1) + metric := scopeMetric.Metrics().AppendEmpty() + fillMetric(t, metric, resourceIndex, scopeIndex) + // TODO: other metric types and value types? + gauge := metric.SetEmptyGauge() + gauge.DataPoints().EnsureCapacity(dpCount[resourceIndex][scopeIndex]) + for dpIndex := 0; dpIndex < dpCount[resourceIndex][scopeIndex]; dpIndex++ { + dp := metric.Gauge().DataPoints().AppendEmpty() + fillDataPoint(t, dp, dpIndex) + } + generatedMetrics.Metrics = append(generatedMetrics.Metrics, &metric) + } + } + + return metricData, generatedMetrics +} + +func fillMetric(t *testing.T, m pmetric.Metric, resourceIndex, scopeIndex int) { + t.Helper() + + m.SetName(fmt.Sprintf("metric %d-%d", resourceIndex, scopeIndex)) + m.SetUnit("test unit") + m.SetDescription("test description") +} + +func fillDataPoint(t *testing.T, dp pmetric.NumberDataPoint, dpIndex int) { + t.Helper() + + dp.SetDoubleValue(float64(dpIndex + 1)) + dp.SetFlags(pmetric.DefaultDataPointFlags.WithNoRecordedValue(false)) + // TODO: examplers + dp.Attributes().PutInt("dp index", int64(dpIndex)) +} diff --git a/tuiexporter/internal/tui/component/metric.go b/tuiexporter/internal/tui/component/metric.go new file mode 100644 index 0000000..3a6cb5a --- /dev/null +++ b/tuiexporter/internal/tui/component/metric.go @@ -0,0 +1,646 @@ +package component + +import ( + "fmt" + "math" + "sort" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/navidys/tvxwidgets" + "github.com/rivo/tview" + "github.com/ymtdzzz/otel-tui/tuiexporter/internal/telemetry" + "go.opentelemetry.io/collector/pdata/pmetric" +) + +const NULL_VALUE_FLOAT64 = math.MaxFloat64 + +type MetricDataForTable struct { + tview.TableContentReadOnly + metrics *[]*telemetry.MetricData +} + +func NewMetricDataForTable(metrics *[]*telemetry.MetricData) MetricDataForTable { + return MetricDataForTable{ + metrics: metrics, + } +} + +// implementations for tview Virtual Table +// see: https://github.com/rivo/tview/wiki/VirtualTable +func (m MetricDataForTable) GetCell(row, column int) *tview.TableCell { + if row >= 0 && row < len(*m.metrics) { + return getCellFromMetrics((*m.metrics)[row], column) + } + return tview.NewTableCell("N/A") +} + +func (m MetricDataForTable) GetRowCount() int { + return len(*m.metrics) +} + +func (m MetricDataForTable) GetColumnCount() int { + // 0: ServiceName + // 1: MetricName + // 2: MetricType + // 3: MetricDataPointCount + return 4 +} + +// getCellFromLog returns a table cell for the given log and column. +func getCellFromMetrics(metric *telemetry.MetricData, column int) *tview.TableCell { + text := "N/A" + + switch column { + case 0: + if sname, ok := metric.ResourceMetric.Resource().Attributes().Get("service.name"); ok { + text = sname.AsString() + } + case 1: + text = metric.Metric.Name() + case 2: + text = metric.Metric.Type().String() + case 3: + switch metric.Metric.Type() { + case pmetric.MetricTypeGauge: + text = fmt.Sprintf("%d", metric.Metric.Gauge().DataPoints().Len()) + case pmetric.MetricTypeSum: + text = fmt.Sprintf("%d", metric.Metric.Sum().DataPoints().Len()) + case pmetric.MetricTypeHistogram: + text = fmt.Sprintf("%d", metric.Metric.Histogram().DataPoints().Len()) + case pmetric.MetricTypeExponentialHistogram: + text = fmt.Sprintf("%d", metric.Metric.ExponentialHistogram().DataPoints().Len()) + case pmetric.MetricTypeSummary: + text = fmt.Sprintf("%d", metric.Metric.Summary().DataPoints().Len()) + } + } + + if text == "" { + text = "N/A" + } + + return tview.NewTableCell(text) +} + +func getMetricInfoTree(m *telemetry.MetricData) *tview.TreeView { + if m == nil { + return nil + } + root := tview.NewTreeNode("Metric") + tree := tview.NewTreeView().SetRoot(root).SetCurrentNode(root) + + mname := tview.NewTreeNode(fmt.Sprintf("name: %s", m.Metric.Name())) + munit := tview.NewTreeNode(fmt.Sprintf("unit: %s", m.Metric.Unit())) + mdesc := tview.NewTreeNode(fmt.Sprintf("description: %s", m.Metric.Description())) + mtype := tview.NewTreeNode(fmt.Sprintf("type: %s", m.Metric.Type().String())) + + root.AddChild(mname) + root.AddChild(munit) + root.AddChild(mdesc) + root.AddChild(mtype) + + // resource info + rm := m.ResourceMetric + r := rm.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", rm.SchemaUrl())) + resource.AddChild(rschema) + + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, r.Attributes().AsRaw()) + resource.AddChild(attrs) + + // scope info + scopes := tview.NewTreeNode("Scopes") + sm := m.ScopeMetric + s := sm.Scope() + scope := tview.NewTreeNode(s.Name()) + sschema := tview.NewTreeNode(fmt.Sprintf("schema url: %s", sm.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) + + // metric + metr := tview.NewTreeNode("Metrics") + scopes.AddChild(metr) + /// metadata + meta := tview.NewTreeNode("Metadata") + metr.AddChild(meta) + appendAttrsSorted(meta, m.Metric.Metadata().AsRaw()) + + /// datapoints + dps := tview.NewTreeNode("Datapoints") + metr.AddChild(dps) + switch m.Metric.Type() { + case pmetric.MetricTypeGauge: + for dpi := 0; dpi < m.Metric.Gauge().DataPoints().Len(); dpi++ { + dp := tview.NewTreeNode(fmt.Sprintf("%d", dpi)) + d := m.Metric.Gauge().DataPoints().At(dpi) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("start timestamp: %s", d.StartTimestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", d.Timestamp().String()))) + // value + val := tview.NewTreeNode("Value") + val.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", d.ValueType().String()))) + val.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", d.IntValue()))) + val.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", d.DoubleValue()))) + dp.AddChild(val) + // flags + flg := tview.NewTreeNode("Flags") + flg.AddChild(tview.NewTreeNode(fmt.Sprintf("no recorded value: %v", d.Flags().NoRecordedValue()))) + dp.AddChild(flg) + // exampler + exs := tview.NewTreeNode("Examplers") + dp.AddChild(exs) + for ei := 0; ei < d.Exemplars().Len(); ei++ { + ex := tview.NewTreeNode(fmt.Sprintf("%d", ei)) + exs.AddChild(ex) + e := d.Exemplars().At(ei) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("trace id: %s", e.TraceID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("span id: %s", e.SpanID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", e.Timestamp().String()))) + // value + v := tview.NewTreeNode("Value") + v.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", e.ValueType().String()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", e.IntValue()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", e.DoubleValue()))) + ex.AddChild(v) + // filtered attributes + fattrs := tview.NewTreeNode("Filtered Attributes") + ex.AddChild(fattrs) + appendAttrsSorted(fattrs, e.FilteredAttributes().AsRaw()) + } + // attributes + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, d.Attributes().AsRaw()) + dp.AddChild(attrs) + + dps.AddChild(dp) + } + case pmetric.MetricTypeSum: + for dpi := 0; dpi < m.Metric.Sum().DataPoints().Len(); dpi++ { + dp := tview.NewTreeNode(fmt.Sprintf("%d", dpi)) + d := m.Metric.Sum().DataPoints().At(dpi) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("start timestamp: %s", d.StartTimestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", d.Timestamp().String()))) + // value + val := tview.NewTreeNode("Value") + val.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", d.ValueType().String()))) + val.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", d.IntValue()))) + val.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", d.DoubleValue()))) + dp.AddChild(val) + // flags + flg := tview.NewTreeNode("Flags") + flg.AddChild(tview.NewTreeNode(fmt.Sprintf("no recorded value: %v", d.Flags().NoRecordedValue()))) + dp.AddChild(flg) + // exampler + exs := tview.NewTreeNode("Examplers") + dp.AddChild(exs) + for ei := 0; ei < d.Exemplars().Len(); ei++ { + ex := tview.NewTreeNode(fmt.Sprintf("%d", ei)) + exs.AddChild(ex) + e := d.Exemplars().At(ei) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("trace id: %s", e.TraceID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("span id: %s", e.SpanID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", e.Timestamp().String()))) + // value + v := tview.NewTreeNode("Value") + v.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", e.ValueType().String()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", e.IntValue()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", e.DoubleValue()))) + ex.AddChild(v) + // filtered attributes + fattrs := tview.NewTreeNode("Filtered Attributes") + ex.AddChild(fattrs) + appendAttrsSorted(fattrs, e.FilteredAttributes().AsRaw()) + } + // attributes + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, d.Attributes().AsRaw()) + dp.AddChild(attrs) + + dps.AddChild(dp) + } + case pmetric.MetricTypeHistogram: + for dpi := 0; dpi < m.Metric.Histogram().DataPoints().Len(); dpi++ { + dp := tview.NewTreeNode(fmt.Sprintf("%d", dpi)) + d := m.Metric.Histogram().DataPoints().At(dpi) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("start timestamp: %s", d.StartTimestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", d.Timestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("count: %d", d.Count()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("bucket counts: %v", d.BucketCounts().AsRaw()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("explicit bounds: %v", d.ExplicitBounds().AsRaw()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("max: %f", d.Max()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("min: %f", d.Min()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("sum: %f", d.Sum()))) + // flags + flg := tview.NewTreeNode("Flags") + flg.AddChild(tview.NewTreeNode(fmt.Sprintf("no recorded value: %v", d.Flags().NoRecordedValue()))) + dp.AddChild(flg) + // exampler + exs := tview.NewTreeNode("Examplers") + dp.AddChild(exs) + for ei := 0; ei < d.Exemplars().Len(); ei++ { + ex := tview.NewTreeNode(fmt.Sprintf("%d", ei)) + exs.AddChild(ex) + e := d.Exemplars().At(ei) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("trace id: %s", e.TraceID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("span id: %s", e.SpanID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", e.Timestamp().String()))) + // value + v := tview.NewTreeNode("Value") + v.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", e.ValueType().String()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", e.IntValue()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", e.DoubleValue()))) + ex.AddChild(v) + // filtered attributes + fattrs := tview.NewTreeNode("Filtered Attributes") + ex.AddChild(fattrs) + appendAttrsSorted(fattrs, e.FilteredAttributes().AsRaw()) + } + // attributes + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, d.Attributes().AsRaw()) + dp.AddChild(attrs) + + dps.AddChild(dp) + } + case pmetric.MetricTypeExponentialHistogram: + for dpi := 0; dpi < m.Metric.ExponentialHistogram().DataPoints().Len(); dpi++ { + dp := tview.NewTreeNode(fmt.Sprintf("%d", dpi)) + d := m.Metric.ExponentialHistogram().DataPoints().At(dpi) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("start timestamp: %s", d.StartTimestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", d.Timestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("count: %d", d.Count()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("scale: %d", d.Scale()))) + neg := tview.NewTreeNode("Negative") + dp.AddChild(neg) + neg.AddChild(tview.NewTreeNode(fmt.Sprintf("bucket counts: %v", d.Negative().BucketCounts().AsRaw()))) + neg.AddChild(tview.NewTreeNode(fmt.Sprintf("offset: %d", d.Negative().Offset()))) + pos := tview.NewTreeNode("Positive") + dp.AddChild(pos) + pos.AddChild(tview.NewTreeNode(fmt.Sprintf("bucket counts: %v", d.Positive().BucketCounts().AsRaw()))) + pos.AddChild(tview.NewTreeNode(fmt.Sprintf("offset: %d", d.Positive().Offset()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("max: %f", d.Max()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("min: %f", d.Min()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("sum: %f", d.Sum()))) + // flags + flg := tview.NewTreeNode("Flags") + flg.AddChild(tview.NewTreeNode(fmt.Sprintf("no recorded value: %v", d.Flags().NoRecordedValue()))) + dp.AddChild(flg) + // exampler + exs := tview.NewTreeNode("Examplers") + dp.AddChild(exs) + for ei := 0; ei < d.Exemplars().Len(); ei++ { + ex := tview.NewTreeNode(fmt.Sprintf("%d", ei)) + exs.AddChild(ex) + e := d.Exemplars().At(ei) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("trace id: %s", e.TraceID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("span id: %s", e.SpanID()))) + ex.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", e.Timestamp().String()))) + // value + v := tview.NewTreeNode("Value") + v.AddChild(tview.NewTreeNode(fmt.Sprintf("type: %s", e.ValueType().String()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("int: %d", e.IntValue()))) + v.AddChild(tview.NewTreeNode(fmt.Sprintf("double: %f", e.DoubleValue()))) + ex.AddChild(v) + // filtered attributes + fattrs := tview.NewTreeNode("Filtered Attributes") + ex.AddChild(fattrs) + appendAttrsSorted(fattrs, e.FilteredAttributes().AsRaw()) + } + // attributes + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, d.Attributes().AsRaw()) + dp.AddChild(attrs) + + dps.AddChild(dp) + } + case pmetric.MetricTypeSummary: + for dpi := 0; dpi < m.Metric.Summary().DataPoints().Len(); dpi++ { + dp := tview.NewTreeNode(fmt.Sprintf("%d", dpi)) + d := m.Metric.Summary().DataPoints().At(dpi) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("start timestamp: %s", d.StartTimestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("timestamp: %s", d.Timestamp().String()))) + dp.AddChild(tview.NewTreeNode(fmt.Sprintf("count: %d", d.Count()))) + d.QuantileValues().At(0).Quantile() + d.QuantileValues().At(0).Value() + // quantile + quants := tview.NewTreeNode("Quantile Values") + dp.AddChild(quants) + for qi := 0; qi < d.QuantileValues().Len(); qi++ { + q := d.QuantileValues().At(qi) + quant := tview.NewTreeNode(fmt.Sprintf("%d", qi)) + quants.AddChild(quant) + quant.AddChild(tview.NewTreeNode(fmt.Sprintf("quantile: %f", q.Quantile()))) + quant.AddChild(tview.NewTreeNode(fmt.Sprintf("value: %f", q.Value()))) + } + // flags + flg := tview.NewTreeNode("Flags") + flg.AddChild(tview.NewTreeNode(fmt.Sprintf("no recorded value: %v", d.Flags().NoRecordedValue()))) + dp.AddChild(flg) + // attributes + attrs := tview.NewTreeNode("Attributes") + appendAttrsSorted(attrs, d.Attributes().AsRaw()) + dp.AddChild(attrs) + + dps.AddChild(dp) + } + } + + root.AddChild(resource) + + tree.SetSelectedFunc(func(node *tview.TreeNode) { + node.SetExpanded(!node.IsExpanded()) + }) + + return tree +} + +type ByTimestamp []*pmetric.NumberDataPoint + +func (a ByTimestamp) Len() int { return len(a) } +func (a ByTimestamp) Swap(i, j int) { a[i], a[j] = a[j], a[i] } +func (a ByTimestamp) Less(i, j int) bool { + return a[i].Timestamp().AsTime().Before(a[j].Timestamp().AsTime()) +} + +func drawMetricChartByRow(store *telemetry.Store, row int) tview.Primitive { + m := store.GetFilteredMetricByIdx(row) + mcache := store.GetMetricCache() + sname := "N/A" + if snameattr, ok := m.ResourceMetric.Resource().Attributes().Get("service.name"); ok { + sname = snameattr.AsString() + } + ms, ok := mcache.GetMetricsBySvcAndMetricName(sname, m.Metric.Name()) + if !ok { + return nil + } + + // attribute name and value map + dataMap := make(map[string]map[string][]*pmetric.NumberDataPoint, 1) + attrkeys := []string{} + + support := true + start := time.Unix(1<<63-62135596801, 999999999) + end := time.Unix(0, 0) + for _, m := range ms { + var ( + attrs map[string]any + dp pmetric.NumberDataPoint + ) + + switch m.Metric.Type() { + case pmetric.MetricTypeGauge: + for dpi := 0; dpi < m.Metric.Gauge().DataPoints().Len(); dpi++ { + dp = m.Metric.Gauge().DataPoints().At(dpi) + attrs = dp.Attributes().AsRaw() + dpts := dp.Timestamp().AsTime() + if dpts.Before(start) { + start = dpts + } + if dpts.After(end) { + end = dpts + } + } + case pmetric.MetricTypeSum: + for dpi := 0; dpi < m.Metric.Sum().DataPoints().Len(); dpi++ { + dp = m.Metric.Sum().DataPoints().At(dpi) + attrs = dp.Attributes().AsRaw() + dpts := dp.Timestamp().AsTime() + if dpts.Before(start) { + start = dpts + } + if dpts.After(end) { + end = dpts + } + } + case pmetric.MetricTypeHistogram: + support = false + case pmetric.MetricTypeExponentialHistogram: + support = false + case pmetric.MetricTypeSummary: + support = false + } + if !support { + break + } + + if len(attrs) > 0 { + // sort keys + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + v := attrs[k] + vstr := fmt.Sprintf("%s", v) + if attrkey, ok := dataMap[k]; ok { + if _, ok := attrkey[vstr]; ok { + dataMap[k][vstr] = append(dataMap[k][vstr], &dp) + } else { + dataMap[k][vstr] = []*pmetric.NumberDataPoint{&dp} + } + } else { + attrkeys = append(attrkeys, k) + dataMap[k] = map[string][]*pmetric.NumberDataPoint{vstr: {&dp}} + } + } + } else { + k := "N/A" + vstr := "N/A" + if attrkey, ok := dataMap[k]; ok { + if _, ok := attrkey[vstr]; ok { + dataMap[k][vstr] = append(dataMap[k][vstr], &dp) + } else { + dataMap[k][vstr] = []*pmetric.NumberDataPoint{&dp} + } + } else { + attrkeys = append(attrkeys, k) + dataMap[k] = map[string][]*pmetric.NumberDataPoint{vstr: {&dp}} + } + } + } + + chart := tview.NewFlex().SetDirection(tview.FlexColumn) + + if !support { + txt := tview.NewTextView().SetText("This metric type is not supported") + chart.AddItem(txt, 0, 1, false) + return chart + } + + for k := range dataMap { + for kk := range dataMap[k] { + sort.Sort(ByTimestamp(dataMap[k][kk])) + } + } + + getTitle := func(idx int) string { + return fmt.Sprintf("%s [%d / %d] ( <- | -> )", attrkeys[idx], idx+1, len(attrkeys)) + } + + // Draw a chart of the first attribute + attrkeyidx := 0 + data, txts := getDataToDraw(dataMap, attrkeys[attrkeyidx], start, end) + ch := tvxwidgets.NewPlot() + ch.SetMarker(tvxwidgets.PlotMarkerBraille) + ch.SetTitle(getTitle(attrkeyidx)) + ch.SetBorder(true) + ch.SetData(data) + ch.SetDrawXAxisLabel(false) + ch.SetLineColor(colors) + + legend := tview.NewFlex().SetDirection(tview.FlexRow) + legend.AddItem(txts, 0, 1, false) + + ch.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyRight: + if attrkeyidx < len(attrkeys)-1 { + attrkeyidx++ + } else { + attrkeyidx = 0 + } + ch.SetTitle(getTitle(attrkeyidx)) + data, txts := getDataToDraw(dataMap, attrkeys[attrkeyidx], start, end) + legend.Clear() + legend.AddItem(txts, 0, 1, false) + ch.SetData(data) + return nil + case tcell.KeyLeft: + if attrkeyidx > 0 { + attrkeyidx-- + } else { + attrkeyidx = len(attrkeys) - 1 + } + ch.SetTitle(getTitle(attrkeyidx)) + data, txts := getDataToDraw(dataMap, attrkeys[attrkeyidx], start, end) + legend.Clear() + legend.AddItem(txts, 0, 1, false) + ch.SetData(data) + return nil + } + return event + }) + + chart.AddItem(ch, 0, 7, true).AddItem(legend, 0, 3, false) + + return chart +} + +func getDataToDraw(dataMap map[string]map[string][]*pmetric.NumberDataPoint, attrkey string, start, end time.Time) ([][]float64, *tview.Flex) { + // Sort keys + keys := make([]string, 0, len(dataMap[attrkey])) + for k := range dataMap[attrkey] { + keys = append(keys, k) + } + sort.Strings(keys) + // Count datapoints + dpnum := 0 + for _, k := range keys { + dpnum += len(dataMap[attrkey][k]) + } + d := make([][]float64, len(keys)) + for i := range d { + d[i] = make([]float64, dpnum) + } + // Set null value + for i := range d { + for ii := range d[i] { + d[i][ii] = NULL_VALUE_FLOAT64 + } + } + txts := tview.NewFlex().SetDirection(tview.FlexRow) + count := 0 + wholedur := end.Sub(start).Nanoseconds() + type locateMap struct { + prevpos int + prevval float64 + pos int + val float64 + } + locatedposmap := make(map[int][]locateMap, len(keys)) + // Set values to timestamp relative position. + // Note that this process keeps values between corresponding positions null value. + // ex: [1.2 1.3 null 1.6 1.1 null null 2.5] + for _, k := range keys { + prevpos := -1 + prevval := NULL_VALUE_FLOAT64 + for _, dp := range dataMap[attrkey][k] { + // Get timestamp and locate it to relative position + dur := dp.Timestamp().AsTime().Sub(start).Nanoseconds() + var ratio float64 + if dur == 0 { + ratio = 0 + } else { + ratio = float64(dur) / float64(wholedur) + } + pos := int(math.Round(float64(dpnum) * ratio)) + if pos >= len(d[count]) { + pos = len(d[count]) - 1 + } + if pos < 0 { + pos = 0 + } + var val float64 + switch dp.ValueType() { + case pmetric.NumberDataPointValueTypeDouble: + val = dp.DoubleValue() + case pmetric.NumberDataPointValueTypeInt: + val = float64(dp.IntValue()) + } + d[count][pos] = val + locatedposmap[count] = append(locatedposmap[count], locateMap{ + prevpos: prevpos, + prevval: prevval, + pos: pos, + val: val, + }) + prevpos = pos + prevval = val + } + txt := tview.NewTextView() + txt.SetTextColor(colors[count]) + txt.SetText(fmt.Sprintf("● %s: %s", attrkey, k)) + txts.AddItem(txt, 2, 1, false) + count++ + } + // Replace null value with appropriate value for smooth line + for i := range d { + for c, pmap := range locatedposmap[i] { + // Fill after the last element + if c == len(locatedposmap[i])-1 && pmap.pos < dpnum { + for j := pmap.pos + 1; j < dpnum; j++ { + d[i][j] = pmap.val + } + } + // Fill before the first element + if pmap.prevpos == -1 { + for j := 0; j < pmap.pos; j++ { + d[i][j] = pmap.val + } + continue + } + split := pmap.pos - pmap.prevpos + diff := pmap.val - pmap.prevval + step := diff / float64(split+1) + curr := pmap.prevval + for j := pmap.prevpos + 1; j < pmap.pos; j++ { + curr += step + d[i][j] = curr + } + } + } + return d, txts +} diff --git a/tuiexporter/internal/tui/component/page.go b/tuiexporter/internal/tui/component/page.go index 8bde9f6..499ac59 100644 --- a/tuiexporter/internal/tui/component/page.go +++ b/tuiexporter/internal/tui/component/page.go @@ -17,6 +17,7 @@ const ( PAGE_TIMELINE = "Timeline" PAGE_LOGS = "Logs" PAGE_DEBUG_LOG = "DebugLog" + PAGE_METRICS = "Metrics" ) var keyMapRegex = regexp.MustCompile(`Rune|\[|\]`) @@ -32,6 +33,7 @@ type TUIPages struct { pages *tview.Pages traces *tview.Flex timeline *tview.Flex + metrics *tview.Flex logs *tview.Flex debuglog *tview.Flex current string @@ -74,6 +76,8 @@ func (p *TUIPages) ToggleLog() { // TogglePage toggles Traces & Logs page. func (p *TUIPages) TogglePage() { if p.current == PAGE_TRACES { + p.switchToPage(PAGE_METRICS) + } else if p.current == PAGE_METRICS { p.switchToPage(PAGE_LOGS) } else { p.switchToPage(PAGE_TRACES) @@ -98,6 +102,10 @@ func (p *TUIPages) registerPages(store *telemetry.Store) { p.timeline = timeline p.pages.AddPage(PAGE_TIMELINE, timeline, true, false) + metrics := p.createMetricsPage(store) + p.metrics = metrics + p.pages.AddPage(PAGE_METRICS, metrics, true, false) + logs := p.createLogPage(store) p.logs = logs p.pages.AddPage(PAGE_LOGS, logs, true, false) @@ -267,6 +275,123 @@ func (p *TUIPages) showTimeline(traceID string, tcache *telemetry.TraceCache, lc p.switchToPage(PAGE_TIMELINE) } +func (p *TUIPages) createMetricsPage(store *telemetry.Store) *tview.Flex { + page := tview.NewFlex().SetDirection(tview.FlexColumn) + + side := tview.NewFlex().SetDirection(tview.FlexRow) + details := tview.NewFlex().SetDirection(tview.FlexRow) + details.SetTitle("Details (d)").SetBorder(true) + + chart := tview.NewFlex().SetDirection(tview.FlexRow) + chart.SetTitle("Chart (c)").SetBorder(true) + + side.AddItem(details, 0, 5, false). + AddItem(chart, 0, 5, false) + + tableContainer := tview.NewFlex().SetDirection(tview.FlexRow) + tableContainer.SetTitle("Metrics (m)").SetBorder(true) + table := tview.NewTable(). + SetBorders(false). + SetSelectable(true, false). + SetContent(NewMetricDataForTable(store.GetFilteredMetrics())) + + input := "" + inputConfirmed := "" + search := tview.NewInputField(). + SetLabel("Filter by service or metric name (/): "). + 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.ApplyFilterMetrics(inputConfirmed) + } else if key == tcell.KeyEsc { + search.SetText(inputConfirmed) + } + p.setFocusFn(table) + }) + + table.SetSelectionChangedFunc(func(row, _ int) { + selected := store.GetFilteredMetricByIdx(row) + details.Clear() + details.AddItem(getMetricInfoTree(selected), 0, 1, true) + // TODO: async rendering with spinner + chart.Clear() + chart.AddItem(drawMetricChartByRow(store, row), 0, 1, true) + }) + + 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, 5, true).AddItem(side, 0, 5, false) + page.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 'm': + p.setFocusFn(tableContainer) + // don't return nil here, because we want to pass the event to the search input + case 'c': + p.setFocusFn(chart) + // 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 + }) + page = attatchCommandList(page, KeyMaps{ + &KeyMap{ + key: tcell.NewEventKey(tcell.KeyRune, 'L', tcell.ModCtrl), + description: "Clear all data", + }, + &KeyMap{ + key: tcell.NewEventKey(tcell.KeyRune, '/', tcell.ModNone), + description: "Search Metrics", + }, + &KeyMap{ + key: tcell.NewEventKey(tcell.KeyEsc, ' ', tcell.ModNone), + description: "(search) Cancel", + }, + &KeyMap{ + key: tcell.NewEventKey(tcell.KeyEnter, ' ', tcell.ModNone), + description: "(search) Confirm", + }, + &KeyMap{ + key: tcell.NewEventKey(tcell.KeyF12, ' ', tcell.ModNone), + description: "(debug) Toggle Log", + }, + }) + page = attatchTab(page, PAGE_METRICS) + + return page +} + func (p *TUIPages) createLogPage(store *telemetry.Store) *tview.Flex { pageContainer := tview.NewFlex().SetDirection(tview.FlexRow) page := tview.NewFlex().SetDirection(tview.FlexColumn) @@ -417,9 +542,11 @@ 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)" + text = "< [yellow]Traces[white] | Metrics | Logs > (Tab to switch)" + case PAGE_METRICS: + text = "< Traces | [yellow]Metrics[white] | Logs > (Tab to switch)" case PAGE_LOGS: - text = "< Traces | [yellow]Logs[white] > (Tab to switch)" + text = "< Traces | Metrics | [yellow]Logs[white] > (Tab to switch)" } tabs := tview.NewTextView().