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