From 4365c787bc142f3c076fe98cf6ed7127e4ba243b Mon Sep 17 00:00:00 2001
From: Steven Swartz <stevenswartz@live.ca>
Date: Sun, 12 Jan 2025 12:22:30 -0500
Subject: [PATCH 1/2] Adds ability to view series in text editor by pressing
 "v" or "enter" when a row is selected

---
 Makefile                   |   6 +-
 cmd/cardinality.go         |  49 +++++++++++-
 cmd/internal/flashmsg.go   |  89 ++++++++++++++++++++++
 cmd/internal/textviewer.go |  53 +++++++++++++
 pkg/scrape/scraper.go      | 148 ++++++++++++++++++++++++++++++-------
 pkg/scrape/series.go       |   2 +
 6 files changed, 317 insertions(+), 30 deletions(-)
 create mode 100644 cmd/internal/flashmsg.go
 create mode 100644 cmd/internal/textviewer.go

diff --git a/Makefile b/Makefile
index 60927da..475e831 100644
--- a/Makefile
+++ b/Makefile
@@ -37,12 +37,12 @@ lint: deps
 
 # Cross compilation
 build-linux:
-	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_linux -v ./cmd/main.go
+	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_linux -v ./cmd
 
 build-windows:
-	CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME).exe -v ./cmd/main.go
+	CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME).exe -v ./cmd
 
 build-mac:
-	CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_mac -v ./cmd/main.go
+	CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BINARY_NAME)_mac -v ./cmd
 
 .PHONY: all build test clean run deps build-linux build-windows build-mac
diff --git a/cmd/cardinality.go b/cmd/cardinality.go
index 6ffaa04..54a351c 100644
--- a/cmd/cardinality.go
+++ b/cmd/cardinality.go
@@ -22,6 +22,7 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 	"github.com/thanos-io/thanos/pkg/extkingpin"
 
+	"github.com/pedro-stanaka/prom-scrape-analyzer/cmd/internal"
 	"github.com/pedro-stanaka/prom-scrape-analyzer/pkg/scrape"
 )
 
@@ -50,11 +51,15 @@ var tableHelp = help.New().ShortHelpView([]key.Binding{
 		key.WithKeys("/"),
 		key.WithHelp("/", "search metrics"),
 	),
+	key.NewBinding(
+		key.WithKeys("enter", "v"),
+		key.WithHelp("v/↵", "view series in text editor"),
+	),
 })
 var searchHelp = help.New().ShortHelpView([]key.Binding{
 	key.NewBinding(
 		key.WithKeys("enter"),
-		key.WithHelp("enter:", "explore table"),
+		key.WithHelp("↵", "explore table"),
 	),
 	key.NewBinding(
 		key.WithKeys("esc"),
@@ -64,15 +69,19 @@ var searchHelp = help.New().ShortHelpView([]key.Binding{
 
 var noFiltering func(info scrape.SeriesInfo) bool = nil
 
+var flashDuration = 5 * time.Second
+
 type seriesTable struct {
 	table            table.Model
 	spinner          spinner.Model
 	searchInput      textinput.Model
 	seriesMap        scrape.SeriesMap
+	seriesScrapeText scrape.SeriesScrapeText
 	loading          bool
 	searchingMetrics bool
 	err              error
 	infoTitle        string
+	flashMsg         internal.TextFlash
 }
 
 func newModel(sm map[string]scrape.SeriesSet, height int) *seriesTable {
@@ -114,6 +123,7 @@ func newModel(sm map[string]scrape.SeriesSet, height int) *seriesTable {
 		searchInput:      ti,
 		loading:          true,
 		searchingMetrics: false,
+		flashMsg:         internal.TextFlash{},
 	}
 
 	return m
@@ -145,10 +155,19 @@ func (m *seriesTable) View() string {
 	}
 
 	var view strings.Builder
+
 	if m.searchingMetrics {
 		view.WriteString(baseStyle.Render(m.searchInput.View()))
 	}
 
+	flashText := m.flashMsg.View()
+	if flashText != "" {
+		if m.searchingMetrics {
+			view.WriteString("\n")
+		}
+		view.WriteString(flashText)
+	}
+
 	view.WriteString("\n")
 	view.WriteString(baseStyle.Render(m.table.View()))
 
@@ -192,6 +211,9 @@ func (m *seriesTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.spinner, cmd = m.spinner.Update(msg)
 			return m, cmd
 		}
+	case internal.UpdateTextFlashMsg:
+		m.flashMsg, cmd = m.flashMsg.Update(msg)
+		return m, cmd
 	case error:
 		m.loading = false
 		m.err = msg
@@ -199,6 +221,7 @@ func (m *seriesTable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case *scrape.Result:
 		m.loading = false
 		m.seriesMap = msg.Series
+		m.seriesScrapeText = msg.SeriesScrapeText
 		m.infoTitle = m.formatInfoTitle(msg)
 		m.setTableRows(noFiltering)
 		return m, nil
@@ -232,6 +255,25 @@ func (m *seriesTable) updateWhileBrowsingTable(msg tea.Msg) (tea.Model, tea.Cmd)
 		case "up":
 			m.table, cmd = m.table.Update(msg)
 			return m, cmd
+		case "enter", "v":
+			selectedRow := m.table.SelectedRow()
+			metricName := selectedRow[0]
+			seriesText := m.seriesScrapeText[metricName]
+
+			infoFlash := m.flashMsg.Flash(
+				metricName+"'s series are now opened in your text editor... ",
+				internal.Info,
+				flashDuration,
+			)
+			displayErrFlash := func() tea.Msg {
+				// This blocks until the editor is closed, so it needs to be run in the background as a tea.Cmd
+				err := internal.ViewInEditor(seriesText)
+				if err != nil {
+					return m.flashMsg.Flash(err.Error(), internal.Error, flashDuration)()
+				}
+				return nil
+			}
+			return m, tea.Batch(infoFlash, displayErrFlash)
 		case "/":
 			m.searchingMetrics = true
 			m.searchInput.SetCursor(int(cursor.CursorBlink))
@@ -251,6 +293,11 @@ func (m *seriesTable) updateWhileSearchingMetrics(msg tea.Msg) (tea.Model, tea.C
 	case tea.KeyMsg:
 		switch msg.String() {
 		case "enter":
+			if !m.searchInput.Focused() {
+				// enter should allow viewing metrics for the filtered row that's selected
+				return m.updateWhileBrowsingTable(msg)
+			}
+
 			// Allow exploring the filtered table
 			m.searchInput.SetCursor(int(cursor.CursorHide))
 			m.searchInput.Blur()
diff --git a/cmd/internal/flashmsg.go b/cmd/internal/flashmsg.go
new file mode 100644
index 0000000..f7afedc
--- /dev/null
+++ b/cmd/internal/flashmsg.go
@@ -0,0 +1,89 @@
+package internal
+
+import (
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+type Level int
+
+const (
+	Info Level = iota
+	Error
+)
+
+// TextFlash is used to temporarily display a message to the user.
+type TextFlash struct {
+	text  string
+	level Level
+}
+
+type UpdateTextFlashMsg struct {
+	text     string
+	level    Level
+	duration time.Duration
+
+	isResetMsg bool
+}
+
+func (t TextFlash) Init() tea.Cmd {
+	return nil
+}
+
+func (t TextFlash) Update(msg tea.Msg) (TextFlash, tea.Cmd) {
+	switch msg := msg.(type) {
+	case UpdateTextFlashMsg:
+		if msg.isResetMsg {
+			t.text = ""
+			t.level = Info
+			return t, nil
+		}
+
+		// Workaround so that existing error messages take priority over new info messages,
+		// which is needed since tea.Batch(infoCmd, errorCmd) creates a race condition
+		if t.level == Error && msg.level == Info {
+			return t, nil
+		}
+
+		t.text = msg.text
+		t.level = msg.level
+		cmd := tea.Tick(msg.duration, func(time.Time) tea.Msg {
+			return UpdateTextFlashMsg{
+				isResetMsg: true,
+			}
+		})
+		return t, cmd
+	}
+	return t, nil
+}
+
+func (t TextFlash) Flash(text string, level Level, duration time.Duration) tea.Cmd {
+	return func() tea.Msg {
+		return UpdateTextFlashMsg{
+			text:     text,
+			level:    level,
+			duration: duration,
+		}
+	}
+}
+
+func (t TextFlash) View() string {
+	if t.text == "" {
+		return ""
+	}
+
+	style := lipgloss.NewStyle().
+		Bold(true).
+		MarginLeft(1)
+
+	switch t.level {
+	case Info:
+		return style.Render("ℹ️  " + t.text)
+	case Error:
+		return style.Render("⚠️  " + t.text + " ⚠️")
+	default:
+		return style.Render(t.text)
+	}
+}
diff --git a/cmd/internal/textviewer.go b/cmd/internal/textviewer.go
new file mode 100644
index 0000000..fb3f939
--- /dev/null
+++ b/cmd/internal/textviewer.go
@@ -0,0 +1,53 @@
+package internal
+
+import (
+	"bytes"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/pkg/errors"
+)
+
+func ViewInEditor(text string) error {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		return errors.New("Please define a text editor to use with the $EDITOR environment variable")
+	}
+
+	tmpfile, err := os.CreateTemp("", "prom-scrape-analyzer-*.txt")
+	defer func() {
+		if tmpfile != nil {
+			_ = tmpfile.Close()
+			_ = os.Remove(tmpfile.Name())
+		}
+	}()
+	if err != nil {
+		return errors.Wrap(err, "failed to create temporary file to display text")
+	}
+
+	_, err = tmpfile.WriteString(text)
+	if err != nil {
+		return errors.Wrap(err, "failed to write text to temporary file")
+	}
+
+	args := strings.Split(editor, " ")
+	args = append(args, tmpfile.Name())
+
+	cmd := exec.Command(args[0], args[1:]...)
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+
+	var stderr bytes.Buffer
+	cmd.Stderr = &stderr
+
+	err = cmd.Run()
+	if err != nil {
+		return err
+	}
+	if stderr.Len() > 0 {
+		return errors.New(stderr.String())
+	}
+
+	return nil
+}
diff --git a/pkg/scrape/scraper.go b/pkg/scrape/scraper.go
index 2d81905..db7a950 100644
--- a/pkg/scrape/scraper.go
+++ b/pkg/scrape/scraper.go
@@ -5,8 +5,10 @@ import (
 	"fmt"
 	"io"
 	"net/http"
+	"regexp"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/go-kit/log"
@@ -66,32 +68,89 @@ func NewPromScraper(scrapeURL string, logger log.Logger, opts ...ScraperOption)
 }
 
 func (ps *PromScraper) Scrape() (*Result, error) {
-	req, err := ps.setupRequest()
-	if err != nil {
-		return nil, err
-	}
+	var (
+		seriesSet        map[string]SeriesSet
+		scrapeErr        error
+		seriesScrapeText SeriesScrapeText
+		textScrapeErr    error
+		wg               sync.WaitGroup
+	)
 
-	resp, err := http.DefaultClient.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
+	// First prioritize scraping PrometheusProto format for access to data about created timestamps and native histograms
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+
+		req, err := ps.setupRequest([]config.ScrapeProtocol{
+			config.PrometheusProto,
+			config.OpenMetricsText1_0_0,
+			config.PrometheusText0_0_4,
+			config.OpenMetricsText0_0_1,
+		})
+		if err != nil {
+			return
+		}
 
-	contentType, body, err := ps.readResponse(resp)
-	if err != nil {
-		return nil, err
-	}
+		resp, err := http.DefaultClient.Do(req)
+		if err != nil {
+			scrapeErr = err
+			return
+		}
+		defer resp.Body.Close()
 
-	ps.lastScrapeContentType = contentType
+		contentType, body, err := ps.readResponse(resp)
+		if err != nil {
+			scrapeErr = err
+			return
+		}
+		ps.lastScrapeContentType = contentType
 
-	metrics, err := ps.extractMetrics(body, contentType)
-	if err != nil {
-		return nil, err
+		seriesSet, scrapeErr = ps.extractMetrics(body, contentType)
+	}()
+
+	// If the above response is in proto format then it isn't in a human-readable format,
+	// so request a format known to be readable in case the user wants to view the series.
+	wg.Add(1)
+	go func() {
+		defer wg.Done()
+
+		textReq, err := ps.setupRequest([]config.ScrapeProtocol{
+			config.OpenMetricsText1_0_0,
+			config.PrometheusText0_0_4,
+			config.OpenMetricsText0_0_1,
+		})
+		if err != nil {
+			textScrapeErr = err
+			return
+		}
+		textResp, err := http.DefaultClient.Do(textReq)
+		if err != nil {
+			textScrapeErr = err
+			return
+		}
+		defer textResp.Body.Close()
+		_, textBody, err := ps.readResponse(textResp)
+		if err != nil {
+			textScrapeErr = err
+			return
+		}
+
+		seriesScrapeText = ps.extractMetricSeriesText(textBody)
+	}()
+
+	wg.Wait()
+
+	if scrapeErr != nil {
+		return nil, scrapeErr
+	}
+	if textScrapeErr != nil {
+		return nil, textScrapeErr
 	}
 
 	return &Result{
-		Series:          metrics,
-		UsedContentType: contentType,
+		Series:           seriesSet,
+		UsedContentType:  ps.lastScrapeContentType,
+		SeriesScrapeText: seriesScrapeText,
 	}, nil
 }
 
@@ -99,19 +158,14 @@ func (ps *PromScraper) LastScrapeContentType() string {
 	return ps.lastScrapeContentType
 }
 
-func (ps *PromScraper) setupRequest() (*http.Request, error) {
+func (ps *PromScraper) setupRequest(accept []config.ScrapeProtocol) (*http.Request, error) {
 	// Scrape the URL and analyze the cardinality.
 	req, err := http.NewRequest("GET", ps.scrapeURL, nil)
 	if err != nil {
 		return nil, err
 	}
 
-	acceptHeader := acceptHeader([]config.ScrapeProtocol{
-		config.PrometheusProto,
-		config.OpenMetricsText1_0_0,
-		config.PrometheusText0_0_4,
-		config.OpenMetricsText0_0_1,
-	})
+	acceptHeader := acceptHeader(accept)
 	req.Header.Set("Accept", acceptHeader)
 	req.Header.Set("Accept-Encoding", "gzip")
 	req.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", strconv.FormatInt(int64(ps.timeout.Seconds()), 10))
@@ -292,6 +346,48 @@ func (ps *PromScraper) extractMetrics(body []byte, contentType string) (map[stri
 	return metrics, nil
 }
 
+func (ps *PromScraper) extractMetricSeriesText(textScrapeResponse []byte) SeriesScrapeText {
+	seriesScrapeText := make(map[string]string)
+	metricNamePattern := regexp.MustCompile(`^[^{\s]+`)
+	lines := strings.Split(string(textScrapeResponse), "\n")
+	// a metric's series are not on consecutive lines for histogram and summary metrics
+	// so a strings.Builder is kept in memory for each metric
+	metricLines := make(map[string]*strings.Builder)
+	for _, line := range lines {
+		if len(line) == 0 {
+			continue
+		}
+
+		var parsedMetric string
+		if strings.HasPrefix(line, "#") {
+			parts := strings.Split(line, " ")
+			if len(parts) >= 3 {
+				parsedMetric = parts[2]
+			}
+		} else {
+			parsedMetric = metricNamePattern.FindString(line)
+		}
+		if parsedMetric == "" {
+			_ = level.Debug(ps.logger).Log("msg", "failed to parse metric name from line", "line", line)
+			continue
+		}
+
+		sb, ok := metricLines[parsedMetric]
+		if !ok {
+			sb = &strings.Builder{}
+			metricLines[parsedMetric] = sb
+		}
+
+		sb.WriteString(line)
+		sb.WriteString("\n")
+	}
+
+	for metric, sb := range metricLines {
+		seriesScrapeText[metric] = sb.String()
+	}
+	return seriesScrapeText
+}
+
 // acceptHeader transforms preference from the options into specific header values as
 // https://www.rfc-editor.org/rfc/rfc9110.html#name-accept defines.
 // No validation is here, we expect scrape protocols to be validated already.
diff --git a/pkg/scrape/series.go b/pkg/scrape/series.go
index 0777ae5..c3b2146 100644
--- a/pkg/scrape/series.go
+++ b/pkg/scrape/series.go
@@ -17,6 +17,7 @@ type Series struct {
 }
 
 type SeriesSet map[uint64]Series
+type SeriesScrapeText map[string]string
 
 func (s SeriesSet) Cardinality() int {
 	return len(s)
@@ -125,6 +126,7 @@ type SeriesMap map[string]SeriesSet
 type Result struct {
 	Series          SeriesMap
 	UsedContentType string
+	SeriesScrapeText
 }
 
 type SeriesInfo struct {

From 54ea7839f673aedce67738d124d9cdd44a48986a Mon Sep 17 00:00:00 2001
From: Steven Swartz <stevenswartz@live.ca>
Date: Wed, 15 Jan 2025 21:46:40 -0500
Subject: [PATCH 2/2] fix lint issues