Skip to content

Commit

Permalink
RSDK-8819: Add interactive mode to FTDC viewing. (#4500)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgottlieb authored Nov 5, 2024
1 parent 821197a commit 45a795a
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 29 deletions.
167 changes: 142 additions & 25 deletions ftdc/cmd/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
package main

import (
"bufio"
"errors"
"fmt"
"io"
"math"
"os"
"os/exec"
"strings"
"time"

"go.viam.com/utils"

Expand All @@ -27,6 +32,28 @@ type gnuplotWriter struct {
metricFiles map[string]*os.File

tempdir string

options graphOptions
}

type graphOptions struct {
// minTimeSeconds and maxTimeSeconds control which datapoints should render based on their
// timestamp. The default is all datapoints (minTimeSeconds: 0, maxTimeSeconds: MaxInt64).
minTimeSeconds int64
maxTimeSeconds int64
}

func defaultGraphOptions() graphOptions {
return graphOptions{
minTimeSeconds: 0,
maxTimeSeconds: math.MaxInt64,
}
}

func nolintPrintln(str ...any) {
// This is a CLI. It's acceptable to output to stdout.
//nolint:forbidigo
fmt.Println(str...)
}

// writeln is a wrapper for Fprintln that panics on any error.
Expand All @@ -42,7 +69,7 @@ func writelnf(toWrite io.Writer, formatStr string, args ...any) {
writeln(toWrite, fmt.Sprintf(formatStr, args...))
}

func newGnuPlotWriter() *gnuplotWriter {
func newGnuPlotWriter(graphOptions graphOptions) *gnuplotWriter {
tempdir, err := os.MkdirTemp("", "ftdc_parser")
if err != nil {
panic(err)
Expand All @@ -51,6 +78,7 @@ func newGnuPlotWriter() *gnuplotWriter {
return &gnuplotWriter{
metricFiles: make(map[string]*os.File),
tempdir: tempdir,
options: graphOptions,
}
}

Expand All @@ -68,32 +96,46 @@ func (gpw *gnuplotWriter) getDatafile(metricName string) io.Writer {
return datafile
}

func (gpw *gnuplotWriter) addPoint(timeInt int64, metricName string, metricValue float32) {
writelnf(gpw.getDatafile(metricName), "%v %.2f", timeInt, metricValue)
func (gpw *gnuplotWriter) addPoint(timeSeconds int64, metricName string, metricValue float32) {
if timeSeconds < gpw.options.minTimeSeconds || timeSeconds > gpw.options.maxTimeSeconds {
return
}

writelnf(gpw.getDatafile(metricName), "%v %.5f", timeSeconds, metricValue)
}

func (gpw *gnuplotWriter) addFlatDatum(datum ftdc.FlatDatum) {
for _, reading := range datum.Readings {
gpw.addPoint(datum.Time, reading.MetricName, reading.Value)
gpw.addPoint(datum.ConvertedTime().Unix(), reading.MetricName, reading.Value)
}
}

// Render runs the compiler and invokes gnuplot, creating an image file.
func (gpw *gnuplotWriter) Render() {
filename := gpw.CompileAndClose()
// The filename was generated by this program -- not via user input.
//nolint:gosec
gnuplotCmd := exec.Command("gnuplot", filename)
outputBytes, err := gnuplotCmd.CombinedOutput()
if err != nil {
nolintPrintln("error running gnuplot:", err)
nolintPrintln("gnuplot output:", string(outputBytes))
}
}

// RenderAndClose writes out the "top-level" file and closes all file handles.
func (gpw *gnuplotWriter) RenderAndClose() {
// Compile writes out all of the underlying files for gnuplot. And returns the "top-level" filename
// that can be input to gnuplot. The returned filename is an absolute path.
func (gpw *gnuplotWriter) CompileAndClose() string {
gnuFile, err := os.CreateTemp(gpw.tempdir, "main")
if err != nil {
panic(err)
}
defer utils.UncheckedErrorFunc(gnuFile.Close)

// We are a CLI, it's appropriate to write to stdout.
//
//nolint:forbidigo
fmt.Println("GNUPlot File:", gnuFile.Name())

// Write a png with width of 1000 pixels and 200 pixels of height per metric/graph.
writelnf(gnuFile, "set term png size %d, %d", 1000, 200*len(gpw.metricFiles))

nolintPrintln("Output file: `plot.png`")
// The output filename
writeln(gnuFile, "set output 'plot.png'")

Expand All @@ -108,34 +150,36 @@ func (gpw *gnuplotWriter) RenderAndClose() {
writeln(gnuFile, "set xlabel 'Time'")
writeln(gnuFile, "set xdata time")

// FTDC does not have negative numbers, so start the Y-axis at 0. Except that it may things like
// position or voltages? Revisit if this can be more granular as a per-graph setting rather than
// a global.
// FTDC does not have negative numbers, so start the Y-axis at 0. Except that some metrics may
// want to be negative like position or voltages? Revisit if this can be more granular as a
// per-graph setting rather than a global.
writeln(gnuFile, "set yrange [0:*]")

for metricName, file := range gpw.metricFiles {
writelnf(gnuFile, "plot '%v' using 1:2 with lines linestyle 7 lw 4 title '%v'", file.Name(), strings.ReplaceAll(metricName, "_", "\\_"))
utils.UncheckedErrorFunc(file.Close)
}

return gnuFile.Name()
}

func main() {
if len(os.Args) < 2 {
// We are a CLI, it's appropriate to write to stdout.
//
//nolint:forbidigo
fmt.Println("Expected an FTDC filename. E.g: go run parser.go <path-to>/viam-server.ftdc")

nolintPrintln("Expected an FTDC filename. E.g: go run parser.go <path-to>/viam-server.ftdc")
return
}

ftdcFile, err := os.Open(os.Args[1])
if err != nil {
// We are a CLI, it's appropriate to write to stdout.
//
//nolint:forbidigo
fmt.Println("Error opening file. File:", os.Args[1], "Err:", err)
//nolint:forbidigo
fmt.Println("Expected an FTDC filename. E.g: go run parser.go <path-to>/viam-server.ftdc")

nolintPrintln("Error opening file. File:", os.Args[1], "Err:", err)

nolintPrintln("Expected an FTDC filename. E.g: go run parser.go <path-to>/viam-server.ftdc")
return
}

Expand All @@ -144,10 +188,83 @@ func main() {
panic(err)
}

gpw := newGnuPlotWriter()
for _, flatDatum := range data {
gpw.addFlatDatum(flatDatum)
}
stdinReader := bufio.NewReader(os.Stdin)
render := true
graphOptions := defaultGraphOptions()
for {
if render {
gpw := newGnuPlotWriter(graphOptions)
for _, flatDatum := range data {
gpw.addFlatDatum(flatDatum)
}

gpw.RenderAndClose()
gpw.Render()
}
render = true

// This is a CLI. It's acceptable to output to stdout.
//nolint:forbidigo
fmt.Print("$ ")
cmd, err := stdinReader.ReadString('\n')
cmd = strings.TrimSpace(cmd)
switch {
case err != nil && errors.Is(err, io.EOF):
nolintPrintln("\nExiting...")
return
case cmd == "quit":
nolintPrintln("Exiting...")
return
case cmd == "h" || cmd == "help":
render = false
nolintPrintln("range <start> <end>")
nolintPrintln("- Only plot datapoints within the given range. \"zoom in\"")
nolintPrintln("- E.g: range 2024-09-24T18:00:00 2024-09-24T18:30:00")
nolintPrintln("- range start 2024-09-24T18:30:00")
nolintPrintln("- range 2024-09-24T18:00:00 end")
nolintPrintln("- All times in UTC")
nolintPrintln()
nolintPrintln("reset range")
nolintPrintln("- Unset any prior range. \"zoom out to full\"")
nolintPrintln()
nolintPrintln("`quit` or Ctrl-d to exit")
case strings.HasPrefix(cmd, "range "):
pieces := strings.SplitN(cmd, " ", 3)
// TrimSpace to remove the newline.
start, end := pieces[1], pieces[2]

if start == "start" {
graphOptions.minTimeSeconds = 0
} else {
goTime, err := time.Parse("2006-01-02T15:04:05", start)
if err != nil {
// This is a CLI. It's acceptable to output to stdout.
//nolint:forbidigo
fmt.Printf("Error parsing start time. Working example: `2024-09-24T18:00:00` Inp: %q Err: %v\n", start, err)
continue
}
graphOptions.minTimeSeconds = goTime.Unix()
}

if end == "end" {
graphOptions.maxTimeSeconds = math.MaxInt64
} else {
goTime, err := time.Parse("2006-01-02T15:04:05", end)
if err != nil {
// This is a CLI. It's acceptable to output to stdout.
//nolint:forbidigo
fmt.Printf("Error parsing end time. Working example: `2024-09-24T18:00:00` Inp: %q Err: %v\n", end, err)
continue
}
graphOptions.maxTimeSeconds = goTime.Unix()
}
case strings.HasPrefix(cmd, "reset range"):
graphOptions.minTimeSeconds = 0
graphOptions.maxTimeSeconds = math.MaxInt64
case len(cmd) == 0:
render = false
default:
nolintPrintln("Unknown command. Type `h` for help.")
render = false
}
}
}
17 changes: 14 additions & 3 deletions ftdc/custom_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import (
"errors"
"fmt"
"io"
"math"
"reflect"
"strings"
"time"

"go.viam.com/rdk/logging"
)
Expand Down Expand Up @@ -96,7 +98,7 @@ func writeDatum(time int64, prev, curr []float32, output io.Writer) error {

// When using floating point numbers, it's customary to avoid `== 0` and `!= 0`. And instead
// compare to some small (epsilon) value.
if diffs[diffIdx] > epsilon {
if math.Abs(float64(diffs[diffIdx])) > epsilon {
diffBits[byteIdx] |= (1 << bitOffset)
}
}
Expand All @@ -112,7 +114,7 @@ func writeDatum(time int64, prev, curr []float32, output io.Writer) error {

// Write out values for metrics that changed across reading.
for idx, diff := range diffs {
if diff > epsilon {
if math.Abs(float64(diff)) > epsilon {
if err := binary.Write(output, binary.BigEndian, curr[idx]); err != nil {
return fmt.Errorf("Error writing values: %w", err)
}
Expand Down Expand Up @@ -337,6 +339,15 @@ type Reading struct {
Value float32
}

// ConvertedTime turns the `Time` int64 value in nanoseconds since the epoch into a `time.Time`
// object in the UTC timezone.
func (flatDatum *FlatDatum) ConvertedTime() time.Time {
nanosPerSecond := int64(1_000_000_000)
seconds := flatDatum.Time / nanosPerSecond
nanos := flatDatum.Time % nanosPerSecond
return time.Unix(seconds, nanos).UTC()
}

// asDatum converts the flat array of `Reading`s into a `datum` object with a two layer `Data` map.
func (flatDatum *FlatDatum) asDatum() datum {
var metricNames []string
Expand Down Expand Up @@ -426,7 +437,7 @@ func ParseWithLogger(rawReader io.Reader, logger logging.Logger) ([]FlatDatum, e
logger.Debugw("Error reading time", "error", err)
return ret, err
}
logger.Debugw("Read time", "time", dataTime)
logger.Debugw("Read time", "time", dataTime, "seconds", dataTime/1e9)

// Read the payload. There will be one float32 value for each diff bit set to `1`, i.e:
// `len(diffedFields)`.
Expand Down
3 changes: 2 additions & 1 deletion ftdc/ftdc.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import (
// without context of a metric name is a simply a "value". Those terms are more relevant to the FTDC
// file format.
type datum struct {
// Time in nanoseconds since the epoch.
Time int64
Data map[string]any

Expand Down Expand Up @@ -279,7 +280,7 @@ func (ftdc *FTDC) conditionalRemoveStatser(name string, generationID int) {
// constructDatum walks all of the registered `statser`s to construct a `datum`.
func (ftdc *FTDC) constructDatum() datum {
datum := datum{
Time: time.Now().Unix(),
Time: time.Now().UnixNano(),
Data: map[string]any{},
}

Expand Down

0 comments on commit 45a795a

Please sign in to comment.