diff --git a/AGENTS.md b/AGENTS.md index 3ae35861..d6ec6303 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,22 @@ Error: one JSON object on stdout with `status: "error"`, `error`, `issues`, and Destructive SQL without `--force`: `status: "action_required"`, `query_kind: "destructive"`, `issues`, and `next_steps` (includes `--force` retry command). +## Metrics + +Query historical or current branch metrics through the public metrics API: + +```bash +pscale metrics show --org --format json --metric queries --metric latency_p99 --period 1h +pscale metrics instant --org --format json --metric planetscale_volume_usage_percentage +pscale metrics report --org --format json --period 1d +``` + +- For `metrics show` and `metrics instant`, `--metric` is required and may be repeated or comma-separated. +- `metrics report` detects whether the database uses MySQL or PostgreSQL and queries a curated set of performance sections. It supports `--period`, custom `--from`/`--to` ranges, and `--steps`; JSON returns a composite report and CSV includes the section name on each row. +- Historical queries support `--period`, or a custom `--from`/`--to` ISO 8601 range, plus `--steps` and dimension filters such as `--tablet-type`, `--keyspace`, `--shard`, `--role`, `--pod`, and `--pods`. +- JSON preserves the API response: historical results contain `start_date`, `end_date`, `interval`, and `series`; each series contains `metric`, `label`, `labels`, and `[Unix timestamp, value]` points. Instant results contain current values grouped by their dimensions. +- Human output summarizes each historical series with latest/min/average/max values and a sparkline. CSV flattens historical samples or instant values to one row each. + ## Diagnostics: insights + inspect Two complementary read-only surfaces. When diagnosing database health or performance, **check both** — they see different things. diff --git a/internal/cmd/metrics/format.go b/internal/cmd/metrics/format.go new file mode 100644 index 00000000..c6d14854 --- /dev/null +++ b/internal/cmd/metrics/format.go @@ -0,0 +1,409 @@ +package metrics + +import ( + "encoding/json" + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" + "unicode" + + "github.com/dustin/go-humanize" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +type seriesSummaryRow struct { + Metric string `header:"metric"` + Series string `header:"series"` + Dimensions string `header:"dimensions"` + Latest string `header:"latest"` + Min string `header:"min"` + Avg string `header:"avg"` + Max string `header:"max"` + Trend string `header:"trend"` +} + +type metricPointCSVRow struct { + Timestamp string `csv:"timestamp"` + Metric string `csv:"metric"` + Label string `csv:"label"` + Labels string `csv:"labels"` + Value float64 `csv:"value"` +} + +type instantHumanRow struct { + Metric string `header:"metric"` + Dimensions string `header:"dimensions"` + Value string `header:"value"` +} + +type instantCSVRow struct { + Metric string `csv:"metric"` + Label string `csv:"label"` + Dimensions string `csv:"dimensions"` + Value string `csv:"value"` +} + +func printSeriesSummary(ch *cmdutil.Helper, database, branch string, response *ps.MetricSeries) error { + if len(response.Series) == 0 { + ch.Printer.Printf("No metric series returned for %s in %s.\n", + printer.BoldBlue(branch), printer.BoldBlue(database)) + return nil + } + + maxPoints := 0 + for _, series := range response.Series { + if len(series.Points) > maxPoints { + maxPoints = len(series.Points) + } + } + + ch.Printer.Printf("Metrics for %s/%s\n", printer.BoldBlue(database), printer.BoldBlue(branch)) + ch.Printer.Printf("Range: %s · interval: %ds · up to %d points/series\n\n", + formatMetricRange(response.StartDate, response.EndDate), response.Interval, maxPoints) + + return ch.Printer.PrintResource(seriesSummaryRows(response)) +} + +func seriesSummaryRows(response *ps.MetricSeries) []*seriesSummaryRow { + rows := make([]*seriesSummaryRow, 0, len(response.Series)) + for _, series := range response.Series { + values := pointValues(series.Points) + if len(values) == 0 { + rows = append(rows, &seriesSummaryRow{ + Metric: humanMetricName(series.Metric), + Series: seriesName(series), + Dimensions: formatLabels(series.Labels), + Latest: "n/a", + Min: "n/a", + Avg: "n/a", + Max: "n/a", + Trend: "—", + }) + continue + } + + min, avg, max := valueStats(values) + rows = append(rows, &seriesSummaryRow{ + Metric: humanMetricName(series.Metric), + Series: seriesName(series), + Dimensions: formatLabels(series.Labels), + Latest: formatMetricValue(series.Metric, values[len(values)-1]), + Min: formatMetricValue(series.Metric, min), + Avg: formatMetricValue(series.Metric, avg), + Max: formatMetricValue(series.Metric, max), + Trend: sparkline(values, 12), + }) + } + return rows +} + +func metricPointRows(response *ps.MetricSeries) []*metricPointCSVRow { + rows := make([]*metricPointCSVRow, 0) + for _, series := range response.Series { + labels, _ := json.Marshal(series.Labels) + for _, point := range series.Points { + if len(point) < 2 { + continue + } + rows = append(rows, &metricPointCSVRow{ + Timestamp: time.Unix(int64(point[0]), 0).UTC().Format(time.RFC3339), + Metric: series.Metric, + Label: series.Label, + Labels: string(labels), + Value: point[1], + }) + } + } + return rows +} + +func instantMetricHumanRows(response *ps.InstantMetrics) []*instantHumanRow { + rows := make([]*instantHumanRow, 0) + for _, metric := range response.Metrics { + name := humanMetricName(metric.Label) + if metric.Label == "" { + name = humanMetricName(metric.Metric) + } + for _, value := range metric.Values { + rows = append(rows, &instantHumanRow{ + Metric: name, + Dimensions: formatInstantDimensions(value, "—"), + Value: formatInstantValue(metric.Metric, value["value"]), + }) + } + } + return rows +} + +func instantMetricCSVRows(response *ps.InstantMetrics) []*instantCSVRow { + rows := make([]*instantCSVRow, 0) + for _, metric := range response.Metrics { + for _, value := range metric.Values { + rows = append(rows, &instantCSVRow{ + Metric: metric.Metric, + Label: metric.Label, + Dimensions: formatInstantDimensions(value, ""), + Value: formatRawValue(value["value"]), + }) + } + } + return rows +} + +func pointValues(points [][]float64) []float64 { + values := make([]float64, 0, len(points)) + for _, point := range points { + if len(point) < 2 || math.IsNaN(point[1]) || math.IsInf(point[1], 0) { + continue + } + values = append(values, point[1]) + } + return values +} + +func valueStats(values []float64) (float64, float64, float64) { + min, max := values[0], values[0] + var sum float64 + for _, value := range values { + min = math.Min(min, value) + max = math.Max(max, value) + sum += value + } + return min, sum / float64(len(values)), max +} + +func sparkline(values []float64, width int) string { + if len(values) == 0 || width <= 0 { + return "—" + } + + samples := values + if len(values) > width { + samples = make([]float64, width) + for i := range samples { + index := int(math.Round(float64(i) * float64(len(values)-1) / float64(width-1))) + samples[i] = values[index] + } + } + + min, _, max := valueStats(samples) + levels := []rune("▁▂▃▄▅▆▇█") + var result strings.Builder + for _, value := range samples { + level := 3 + if max != min { + level = int(math.Round((value - min) / (max - min) * float64(len(levels)-1))) + } + result.WriteRune(levels[level]) + } + return result.String() +} + +func formatMetricRange(start, end time.Time) string { + localStart, localEnd := start.Local(), end.Local() + if localStart.YearDay() == localEnd.YearDay() && localStart.Year() == localEnd.Year() { + return fmt.Sprintf("%s–%s", localStart.Format("2006-01-02 15:04"), localEnd.Format("15:04 MST")) + } + return fmt.Sprintf("%s–%s", localStart.Format("2006-01-02 15:04 MST"), localEnd.Format("2006-01-02 15:04 MST")) +} + +func seriesName(series *ps.TimeSeries) string { + if series.Label == "" || normalizeName(series.Label) == normalizeName(humanMetricName(series.Metric)) { + return "—" + } + return series.Label +} + +func formatLabels(labels map[string]string) string { + if len(labels) == 0 { + return "—" + } + keys := make([]string, 0, len(labels)) + for key := range labels { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", key, labels[key])) + } + return strings.Join(parts, ", ") +} + +func formatInstantDimensions(value map[string]any, empty string) string { + keys := make([]string, 0, len(value)) + for key := range value { + if key != "value" && value[key] != nil && fmt.Sprint(value[key]) != "" { + keys = append(keys, key) + } + } + if len(keys) == 0 { + return empty + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", key, value[key])) + } + return strings.Join(parts, ", ") +} + +func humanMetricName(name string) string { + name = strings.TrimPrefix(name, "planetscale_") + name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) + if name == "" { + return "Metric" + } + + words := strings.Fields(name) + replacements := map[string]string{ + "cpu": "CPU", + "iops": "IOPS", + "oom": "OOM", + "pgbouncer": "PgBouncer", + "postgres": "PostgreSQL", + "rss": "RSS", + "util": "utilization", + "percentages": "percentage", + "vtgate": "VTGate", + "vreplication": "VReplication", + "wal": "WAL", + } + for i, word := range words { + if replacement, ok := replacements[strings.ToLower(word)]; ok { + words[i] = replacement + } + } + if _, ok := replacements[strings.ToLower(words[0])]; !ok { + runes := []rune(words[0]) + runes[0] = unicode.ToUpper(runes[0]) + words[0] = string(runes) + } + return strings.Join(words, " ") +} + +func normalizeName(name string) string { + return strings.Join(strings.Fields(strings.ToLower(name)), " ") +} + +type unit int + +const ( + unitNumber unit = iota + unitBytes + unitBytesPerSecond + unitPercent + unitMilliseconds + unitSeconds +) + +func metricUnit(metric string) unit { + lower := strings.ToLower(metric) + if strings.Contains(lower, "bytes") && strings.HasSuffix(lower, "_rate") { + return unitBytesPerSecond + } + if strings.Contains(lower, "bytes") || lower == "storage_per_table" || lower == "shard_storage_usage" || lower == "shard_storage_available" || lower == "planetscale_primary_storage_usage" { + return unitBytes + } + if strings.Contains(lower, "percent") || strings.Contains(lower, "cpu_by_az") || strings.Contains(lower, "memory_by_az") || strings.HasSuffix(lower, "cpu_usage") || strings.HasSuffix(lower, "memory_usage") || lower == "block_cache_hit_ratio" { + return unitPercent + } + if strings.Contains(lower, "latency") || strings.Contains(lower, "duration_millis") { + return unitMilliseconds + } + if strings.Contains(lower, "lag") || strings.HasSuffix(lower, "_seconds") || strings.HasSuffix(lower, "_age_succeeded") { + return unitSeconds + } + return unitNumber +} + +func formatMetricValue(metric string, value float64) string { + switch metricUnit(metric) { + case unitBytes: + if value >= 0 { + return humanize.IBytes(uint64(math.Round(value))) + } + case unitBytesPerSecond: + if value >= 0 { + return humanize.IBytes(uint64(math.Round(value))) + "/s" + } + case unitPercent: + return formatSignificant(value, 4) + "%" + case unitMilliseconds: + return formatSignificant(value, 4) + " ms" + case unitSeconds: + return formatSignificant(value, 4) + " s" + } + return formatNumber(value) +} + +func formatInstantValue(metric string, value any) string { + if number, ok := numericValue(value); ok { + return formatMetricValue(metric, number) + } + return formatRawValue(value) +} + +func numericValue(value any) (float64, bool) { + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case json.Number: + f, err := v.Float64() + return f, err == nil + default: + return 0, false + } +} + +func formatRawValue(value any) string { + if number, ok := numericValue(value); ok { + return strconv.FormatFloat(number, 'f', -1, 64) + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func formatNumber(value float64) string { + if math.Abs(value-math.Round(value)) < 1e-9 && value <= math.MaxInt64 && value >= math.MinInt64 { + return humanize.Comma(int64(math.Round(value))) + } + return formatSignificant(value, 4) +} + +func formatSignificant(value float64, digits int) string { + if value == 0 || digits <= 0 { + return "0" + } + + magnitude := int(math.Floor(math.Log10(math.Abs(value)))) + decimals := digits - magnitude - 1 + if decimals < 0 { + decimals = 0 + } + if decimals > 12 { + decimals = 12 + } + + scale := math.Pow10(decimals) + rounded := math.Round(value*scale) / scale + formatted := humanize.CommafWithDigits(rounded, decimals) + if strings.Contains(formatted, ".") { + formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".") + } + return formatted +} diff --git a/internal/cmd/metrics/instant.go b/internal/cmd/metrics/instant.go new file mode 100644 index 00000000..776d0111 --- /dev/null +++ b/internal/cmd/metrics/instant.go @@ -0,0 +1,84 @@ +package metrics + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +// InstantCmd queries the current values of branch metrics. +func InstantCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + metrics []string + role string + shard string + container string + pod string + } + + cmd := &cobra.Command{ + Use: "instant ", + Short: "Show current metric values", + Example: ` # Show current disk utilization for every Postgres pod + pscale metrics instant mydb main --org myorg --metric planetscale_volume_usage_percentage + + # Preserve the complete instant metrics API response + pscale metrics instant mydb main --org myorg --metric planetscale_volume_usage_percentage --format json`, + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := ch.Client() + if err != nil { + return err + } + + database, branch := args[0], args[1] + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching current metrics for %s in %s...", + printer.BoldBlue(branch), printer.BoldBlue(database))) + defer end() + + metrics, err := client.Metrics.GetInstant(cmd.Context(), &ps.GetInstantMetricsRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Metrics: flags.metrics, + Role: flags.role, + Shard: flags.shard, + Container: flags.container, + Pod: flags.pod, + }) + if err != nil { + return cmdutil.HandleError(err) + } + end() + + if ch.Printer.Format() == printer.JSON { + return ch.Printer.PrintJSON(metrics) + } + + if ch.Printer.Format() == printer.CSV { + return ch.Printer.PrintResource(instantMetricCSVRows(metrics)) + } + + rows := instantMetricHumanRows(metrics) + if len(rows) == 0 && ch.Printer.Format() == printer.Human { + ch.Printer.Printf("No current metric values returned for %s in %s.\n", + printer.BoldBlue(branch), printer.BoldBlue(database)) + return nil + } + return ch.Printer.PrintResource(rows) + }, + } + + cmd.Flags().StringSliceVar(&flags.metrics, "metric", nil, "Metric to query (repeat or comma-separate)") + cmd.Flags().StringVar(&flags.role, "role", "", "Filter by Postgres role") + cmd.Flags().StringVar(&flags.shard, "shard", "", "Filter by shard") + cmd.Flags().StringVar(&flags.container, "container", "", "Filter by container") + cmd.Flags().StringVar(&flags.pod, "pod", "", "Filter by pod") + cmd.MarkFlagRequired("metric") // nolint:errcheck + + return cmd +} diff --git a/internal/cmd/metrics/metrics.go b/internal/cmd/metrics/metrics.go new file mode 100644 index 00000000..64d73f1b --- /dev/null +++ b/internal/cmd/metrics/metrics.go @@ -0,0 +1,31 @@ +package metrics + +import ( + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" +) + +// MetricsCmd queries historical and current metrics for a database branch. +func MetricsCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics ", + Short: "Query historical and current metrics for a database branch", + Long: `Query PlanetScale's metrics service for a database branch. + +Human output summarizes historical series and formats current values for quick +inspection. JSON preserves the API response, while CSV emits one row per sample +or current value for use in scripts and analysis tools.`, + PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config), + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, + "The organization for the current user") + cmd.MarkPersistentFlagRequired("org") // nolint:errcheck + + cmd.AddCommand(ShowCmd(ch)) + cmd.AddCommand(InstantCmd(ch)) + cmd.AddCommand(ReportCmd(ch)) + + return cmd +} diff --git a/internal/cmd/metrics/metrics_test.go b/internal/cmd/metrics/metrics_test.go new file mode 100644 index 00000000..76d6d833 --- /dev/null +++ b/internal/cmd/metrics/metrics_test.go @@ -0,0 +1,238 @@ +package metrics + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + "time" + + qt "github.com/frankban/quicktest" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func metricsTestHelper(buf *bytes.Buffer, format printer.Format, client *ps.Client) *cmdutil.Helper { + p := printer.NewPrinter(&format) + if format == printer.Human { + p.SetHumanOutput(buf) + } + p.SetResourceOutput(buf) + return &cmdutil.Helper{ + Printer: p, + Config: &config.Config{Organization: "planetscale"}, + Client: func() (*ps.Client, error) { + return client, nil + }, + } +} + +func sampleSeries() *ps.MetricSeries { + return &ps.MetricSeries{ + Type: "MetricSeries", + StartDate: time.Date(2026, 8, 18, 16, 0, 0, 0, time.UTC), + EndDate: time.Date(2026, 8, 18, 17, 0, 0, 0, time.UTC), + Interval: 60, + Series: []*ps.TimeSeries{{ + Type: "TimeSeries", + Metric: "queries", + Label: "Queries", + Labels: map[string]string{}, + Points: [][]float64{{1787068800, 912}, {1787068860, 1284}, {1787068920, 1903}}, + }}, + } +} + +func TestShowCmd_JSONPreservesSeries(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetSeriesFn: func(ctx context.Context, req *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + c.Assert(req.Organization, qt.Equals, "planetscale") + c.Assert(req.Database, qt.Equals, "mydb") + c.Assert(req.Branch, qt.Equals, "main") + c.Assert(req.Metrics, qt.DeepEquals, []string{"queries", "latency_p99"}) + c.Assert(req.Period, qt.Equals, "1h") + c.Assert(req.Role, qt.Equals, "primary") + return sampleSeries(), nil + }, + } + + var buf bytes.Buffer + cmd := ShowCmd(metricsTestHelper(&buf, printer.JSON, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "queries,latency_p99", "--period", "1h", "--role", "primary"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(service.GetSeriesFnInvoked, qt.IsTrue) + + var response ps.MetricSeries + c.Assert(json.Unmarshal(buf.Bytes(), &response), qt.IsNil) + c.Assert(response.Type, qt.Equals, "MetricSeries") + c.Assert(response.Series[0].Points, qt.HasLen, 3) +} + +func TestShowCmd_HumanSummarizesSeries(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetSeriesFn: func(context.Context, *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + return sampleSeries(), nil + }, + } + + var buf bytes.Buffer + cmd := ShowCmd(metricsTestHelper(&buf, printer.Human, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "queries", "--period", "1h"}) + c.Assert(cmd.Execute(), qt.IsNil) + + output := buf.String() + c.Assert(output, qt.Contains, "Metrics for mydb/main") + c.Assert(output, qt.Contains, "60s") + c.Assert(output, qt.Contains, "Queries") + c.Assert(output, qt.Contains, "1,903") + c.Assert(output, qt.Contains, "▁") +} + +func TestShowCmd_CSVFlattensPoints(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetSeriesFn: func(context.Context, *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + return sampleSeries(), nil + }, + } + + var buf bytes.Buffer + cmd := ShowCmd(metricsTestHelper(&buf, printer.CSV, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "queries"}) + c.Assert(cmd.Execute(), qt.IsNil) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + c.Assert(lines, qt.HasLen, 4) + c.Assert(lines[0], qt.Equals, "timestamp,metric,label,labels,value") + c.Assert(lines[1], qt.Contains, "2026-08-18T16:00:00Z,queries,Queries,{}") +} + +func TestShowCmd_RejectsIncompleteCustomRange(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{} + cmd := ShowCmd(metricsTestHelper(&bytes.Buffer{}, printer.JSON, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "queries", "--from", "2026-08-18T16:00:00Z"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, ".*--from and --to must be used together.*") + c.Assert(service.GetSeriesFnInvoked, qt.IsFalse) +} + +func TestHumanMetricFormatting(t *testing.T) { + c := qt.New(t) + tests := []struct { + metric string + value float64 + want string + }{ + {metric: "queries", value: 1284, want: "1,284"}, + {metric: "latency_p99", value: 18.2, want: "18.2 ms"}, + {metric: "planetscale_volume_usage_percentage", value: 71.4, want: "71.4%"}, + {metric: "planetscale_primary_storage_usage", value: 1073741824, want: "1.0 GiB"}, + {metric: "planetscale_edge_bytes_received", value: 10485760, want: "10 MiB"}, + {metric: "planetscale_edge_bytes_received_rate", value: 10485760, want: "10 MiB/s"}, + {metric: "planetscale_edge_bytes_sent_rate", value: 1536, want: "1.5 KiB/s"}, + {metric: "block_cache_hit_ratio", value: 99.2, want: "99.2%"}, + {metric: "vtgate_cpu_by_az", value: 42.3, want: "42.3%"}, + } + + for _, test := range tests { + c.Run(test.metric, func(c *qt.C) { + c.Assert(formatMetricValue(test.metric, test.value), qt.Equals, test.want) + }) + } + c.Assert(humanMetricName("latency_p99"), qt.Equals, "Latency p99") + c.Assert(formatNumber(123.456789), qt.Equals, "123.5") + c.Assert(formatNumber(12.3456789), qt.Equals, "12.35") + c.Assert(formatNumber(0.00123456789), qt.Equals, "0.001235") +} + +func TestSeriesSummaryRowsFormatsByteRates(t *testing.T) { + c := qt.New(t) + response := sampleSeries() + response.Series[0].Metric = "planetscale_edge_bytes_received_rate" + response.Series[0].Points = [][]float64{{1787068800, 10485760}} + + rows := seriesSummaryRows(response) + c.Assert(rows, qt.HasLen, 1) + c.Assert(rows[0].Latest, qt.Equals, "10 MiB/s") + c.Assert(rows[0].Min, qt.Equals, "10 MiB/s") + c.Assert(rows[0].Avg, qt.Equals, "10 MiB/s") + c.Assert(rows[0].Max, qt.Equals, "10 MiB/s") +} + +func sampleInstantMetrics() *ps.InstantMetrics { + return &ps.InstantMetrics{ + Type: "InstantMetrics", + Branch: map[string]any{"id": "branch-id", "name": "main"}, + Metrics: []*ps.InstantMetric{{ + Metric: "planetscale_volume_usage_percentage", + Label: "volume_usage", + Values: []map[string]any{{"pod": "postgres-0", "role": "primary", "value": 71.4}}, + }}, + } +} + +func TestInstantCmd_HumanFormatsValues(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetInstantFn: func(ctx context.Context, req *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) { + c.Assert(req.Role, qt.Equals, "primary") + return sampleInstantMetrics(), nil + }, + } + + var buf bytes.Buffer + cmd := InstantCmd(metricsTestHelper(&buf, printer.Human, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "planetscale_volume_usage_percentage", "--role", "primary"}) + c.Assert(cmd.Execute(), qt.IsNil) + + output := buf.String() + c.Assert(output, qt.Contains, "Volume usage") + c.Assert(output, qt.Contains, "pod=postgres-0, role=primary") + c.Assert(output, qt.Contains, "71.4%") +} + +func TestInstantCmd_JSONPreservesEnvelope(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetInstantFn: func(context.Context, *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) { + return sampleInstantMetrics(), nil + }, + } + + var buf bytes.Buffer + cmd := InstantCmd(metricsTestHelper(&buf, printer.JSON, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "planetscale_volume_usage_percentage"}) + c.Assert(cmd.Execute(), qt.IsNil) + + var response ps.InstantMetrics + c.Assert(json.Unmarshal(buf.Bytes(), &response), qt.IsNil) + c.Assert(response.Type, qt.Equals, "InstantMetrics") + c.Assert(response.Metrics[0].Values[0]["value"], qt.Equals, 71.4) +} + +func TestInstantCmd_CSVFlattensValues(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetInstantFn: func(context.Context, *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) { + return sampleInstantMetrics(), nil + }, + } + + var buf bytes.Buffer + cmd := InstantCmd(metricsTestHelper(&buf, printer.CSV, &ps.Client{Metrics: service})) + cmd.SetArgs([]string{"mydb", "main", "--metric", "planetscale_volume_usage_percentage"}) + c.Assert(cmd.Execute(), qt.IsNil) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + c.Assert(lines, qt.HasLen, 2) + c.Assert(lines[0], qt.Equals, "metric,label,dimensions,value") + c.Assert(lines[1], qt.Contains, "planetscale_volume_usage_percentage,volume_usage") + c.Assert(lines[1], qt.Contains, "71.4") +} diff --git a/internal/cmd/metrics/report.go b/internal/cmd/metrics/report.go new file mode 100644 index 00000000..2625ed22 --- /dev/null +++ b/internal/cmd/metrics/report.go @@ -0,0 +1,261 @@ +package metrics + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +type metricsReport struct { + Type string `json:"type"` + Organization string `json:"organization"` + Database string `json:"database"` + Branch string `json:"branch"` + Engine ps.DatabaseEngine `json:"engine"` + Period string `json:"period,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Steps int `json:"steps,omitempty"` + Sections []*metricsReportSection `json:"sections"` +} + +type metricsReportSection struct { + Name string `json:"name"` + Kind reportSectionKind `json:"kind"` + Result any `json:"result"` +} + +type metricsReportCSVRow struct { + Section string `csv:"section"` + Kind string `csv:"kind"` + Timestamp string `csv:"timestamp"` + Metric string `csv:"metric"` + Series string `csv:"series"` + Dimensions string `csv:"dimensions"` + Value string `csv:"value"` +} + +// ReportCmd produces an engine-aware, grouped metrics report for a branch. +func ReportCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + period string + from string + to string + steps int + } + + cmd := &cobra.Command{ + Use: "report ", + Short: "Produce a grouped performance metrics report", + Long: `Produce a curated performance report for a database branch. + +The database engine is detected automatically. MySQL and PostgreSQL reports use +different metric sections, including current-value sections where applicable. +Section headings are bold in human output and plain text with --no-color.`, + Example: ` # Daily human-readable performance report + pscale metrics report mydb main --org myorg --period 1d + + # Weekly report without terminal styling + pscale metrics report mydb main --org myorg --period 7d --no-color + + # Composite JSON report for automation + pscale metrics report mydb main --org myorg --period 1d --format json`, + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRangeFlags(cmd, flags.from, flags.to); err != nil { + return err + } + if cmd.Flags().Changed("steps") && flags.steps <= 0 { + return fmt.Errorf("--steps must be greater than zero") + } + + client, err := ch.Client() + if err != nil { + return err + } + + database, branch := args[0], args[1] + progress := ch.Printer.StartProgress(fmt.Sprintf("Preparing metrics report for %s in %s...", + printer.BoldBlue(branch), printer.BoldBlue(database))) + defer progress.Stop() + + db, err := client.Databases.Get(cmd.Context(), &ps.GetDatabaseRequest{ + Organization: ch.Config.Organization, + Database: database, + }) + if err != nil { + return cmdutil.HandleError(err) + } + + definitions, err := reportSectionsForEngine(db.Kind) + if err != nil { + return err + } + + period := flags.period + if flags.from != "" { + period = "" + } + report := &metricsReport{ + Type: "MetricsReport", + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Engine: db.Kind, + Period: period, + From: flags.from, + To: flags.to, + Steps: flags.steps, + Sections: make([]*metricsReportSection, 0, len(definitions)), + } + + for _, definition := range definitions { + progress.Update(fmt.Sprintf("Fetching %s...", definition.Name)) + section := &metricsReportSection{Name: definition.Name, Kind: definition.Kind} + switch definition.Kind { + case reportSeriesSection: + result, err := client.Metrics.GetSeries(cmd.Context(), &ps.GetMetricSeriesRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Metrics: definition.Metrics, + Period: period, + From: flags.from, + To: flags.to, + Steps: flags.steps, + }) + if err != nil { + return fmt.Errorf("fetching report section %q: %w", definition.Name, cmdutil.HandleError(err)) + } + section.Result = result + case reportInstantSection: + result, err := client.Metrics.GetInstant(cmd.Context(), &ps.GetInstantMetricsRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Metrics: definition.Metrics, + }) + if err != nil { + return fmt.Errorf("fetching report section %q: %w", definition.Name, cmdutil.HandleError(err)) + } + section.Result = result + default: + return fmt.Errorf("unsupported metrics report section kind %q", definition.Kind) + } + report.Sections = append(report.Sections, section) + } + progress.Stop() + + switch ch.Printer.Format() { + case printer.JSON: + return ch.Printer.PrintJSON(report) + case printer.CSV: + return ch.Printer.PrintResource(metricsReportCSVRows(report)) + default: + return printMetricsReport(ch, report) + } + }, + } + + cmd.Flags().StringVar(&flags.period, "period", "1d", "Named report period (15m, 1h, 3h, 6h, 12h, 1d, 2d, 7d, or 8d)") + cmd.Flags().StringVar(&flags.from, "from", "", "Start of a custom time range as an ISO 8601 timestamp") + cmd.Flags().StringVar(&flags.to, "to", "", "End of a custom time range as an ISO 8601 timestamp") + cmd.Flags().IntVar(&flags.steps, "steps", 0, "Requested number of historical data points") + + return cmd +} + +func printMetricsReport(ch *cmdutil.Helper, report *metricsReport) error { + ch.Printer.Printf("%s\n", printer.Bold(fmt.Sprintf("Metrics report for %s/%s/%s", report.Organization, report.Database, report.Branch))) + if start, end, interval, ok := reportRange(report); ok { + ch.Printer.Printf("Range: %s · interval: %ds\n", formatMetricRange(start, end), interval) + } else if report.Period != "" { + ch.Printer.Printf("Period: %s\n", report.Period) + } else { + ch.Printer.Printf("Range: %s–%s\n", report.From, report.To) + } + + for _, section := range report.Sections { + ch.Printer.Printf("\n%s\n\n", printer.Bold(section.Name)) + switch result := section.Result.(type) { + case *ps.MetricSeries: + rows := seriesSummaryRows(result) + if len(rows) == 0 { + ch.Printer.Printf("No metrics returned.\n") + continue + } + if err := ch.Printer.PrintResource(rows); err != nil { + return err + } + case *ps.InstantMetrics: + rows := instantMetricHumanRows(result) + if len(rows) == 0 { + ch.Printer.Printf("No metrics returned.\n") + continue + } + if err := ch.Printer.PrintResource(rows); err != nil { + return err + } + default: + return fmt.Errorf("unsupported result type %T for report section %q", section.Result, section.Name) + } + } + + return nil +} + +func metricsReportCSVRows(report *metricsReport) []*metricsReportCSVRow { + rows := make([]*metricsReportCSVRow, 0) + for _, section := range report.Sections { + switch result := section.Result.(type) { + case *ps.MetricSeries: + for _, series := range result.Series { + dimensions, _ := json.Marshal(series.Labels) + for _, point := range series.Points { + if len(point) < 2 { + continue + } + rows = append(rows, &metricsReportCSVRow{ + Section: section.Name, + Kind: string(section.Kind), + Timestamp: time.Unix(int64(point[0]), 0).UTC().Format(time.RFC3339), + Metric: series.Metric, + Series: series.Label, + Dimensions: string(dimensions), + Value: formatRawValue(point[1]), + }) + } + } + case *ps.InstantMetrics: + for _, metric := range result.Metrics { + for _, value := range metric.Values { + rows = append(rows, &metricsReportCSVRow{ + Section: section.Name, + Kind: string(section.Kind), + Metric: metric.Metric, + Series: metric.Label, + Dimensions: formatInstantDimensions(value, ""), + Value: formatRawValue(value["value"]), + }) + } + } + } + } + return rows +} + +func reportRange(report *metricsReport) (time.Time, time.Time, int, bool) { + for _, section := range report.Sections { + series, ok := section.Result.(*ps.MetricSeries) + if ok && !series.StartDate.IsZero() && !series.EndDate.IsZero() { + return series.StartDate, series.EndDate, series.Interval, true + } + } + return time.Time{}, time.Time{}, 0, false +} diff --git a/internal/cmd/metrics/report_catalog.go b/internal/cmd/metrics/report_catalog.go new file mode 100644 index 00000000..a6080e95 --- /dev/null +++ b/internal/cmd/metrics/report_catalog.go @@ -0,0 +1,310 @@ +package metrics + +import ( + "fmt" + + ps "github.com/planetscale/cli/internal/planetscale" +) + +type reportSectionKind string + +const ( + reportSeriesSection reportSectionKind = "series" + reportInstantSection reportSectionKind = "instant" +) + +type reportSectionDefinition struct { + Name string + Kind reportSectionKind + Metrics []string +} + +var mysqlReportSections = []reportSectionDefinition{ + { + Name: "Workload, errors, and traffic control", + Kind: reportSeriesSection, + Metrics: []string{ + "queries", + "query_errors", + "connections", + "rows_read", + "rows_returned", + "rows_written", + "violations", + "traffic_control_warnings", + "traffic_control_throttled", + }, + }, + { + Name: "Latency and execution time", + Kind: reportSeriesSection, + Metrics: []string{ + "latency_p50", + "latency_p95", + "latency_p99", + "latency_p999", + "latency_max", + "vtgate_latency_p50", + "vtgate_latency_p95", + "total_duration_millis", + "cpu_duration_millis", + "io_duration_millis", + }, + }, + { + Name: "Query efficiency and fan-out", + Kind: reportSeriesSection, + Metrics: []string{ + "rows_read_per_query", + "rows_returned_per_query", + "rows_affected_per_query", + "rows_read_per_returned", + "avg_shard_queries", + "max_shard_queries", + "avg_parallel_workers", + }, + }, + { + Name: "Buffer and block activity", + Kind: reportSeriesSection, + Metrics: []string{ + "blocks_hit", + "blocks_read", + "block_cache_hit_ratio", + "blocks_dirtied", + "blocks_written", + }, + }, + { + Name: "Network traffic", + Kind: reportSeriesSection, + Metrics: []string{ + "ingress_bytes", + "ingress_bytes_per_query", + "max_ingress_bytes", + "egress_bytes", + "egress_bytes_per_query", + "max_egress_bytes", + }, + }, + { + Name: "VTGate utilization by availability zone", + Kind: reportSeriesSection, + Metrics: []string{ + "vtgate_requests", + "vtgate_cpu_by_az", + "vtgate_cpu_avg_by_az", + "vtgate_memory_by_az", + "vtgate_memory_avg_by_az", + }, + }, + { + Name: "Storage by table", + Kind: reportSeriesSection, + Metrics: []string{"storage_per_table"}, + }, +} + +var postgresReportSections = []reportSectionDefinition{ + { + Name: "Workload, errors, and traffic control", + Kind: reportSeriesSection, + Metrics: []string{ + "queries", + "query_errors", + "connections", + "rows_read", + "rows_returned", + "rows_written", + "violations", + "traffic_control_warnings", + "traffic_control_throttled", + }, + }, + { + Name: "Latency and execution time", + Kind: reportSeriesSection, + Metrics: []string{ + "latency_p50", + "latency_p95", + "latency_p99", + "latency_p999", + "latency_max", + "total_duration_millis", + "cpu_duration_millis", + "io_duration_millis", + }, + }, + { + Name: "Query efficiency and distribution", + Kind: reportSeriesSection, + Metrics: []string{ + "rows_read_per_query", + "rows_returned_per_query", + "rows_affected_per_query", + "rows_read_per_returned", + "avg_shard_queries", + "max_shard_queries", + "avg_parallel_workers", + }, + }, + { + Name: "Buffer and block activity", + Kind: reportSeriesSection, + Metrics: []string{ + "blocks_hit", + "blocks_read", + "block_cache_hit_ratio", + "blocks_dirtied", + "blocks_written", + }, + }, + { + Name: "Query network traffic", + Kind: reportSeriesSection, + Metrics: []string{ + "ingress_bytes", + "ingress_bytes_per_query", + "max_ingress_bytes", + "egress_bytes", + "egress_bytes_per_query", + "max_egress_bytes", + }, + }, + { + Name: "Edge network traffic", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_edge_bytes_received", + "planetscale_edge_bytes_received_rate", + "planetscale_edge_bytes_sent", + "planetscale_edge_bytes_sent_rate", + }, + }, + { + Name: "Connections and connection pooling", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_dedicated_pgbouncer_current_connections", + "planetscale_dedicated_pgbouncer_cpu_usage", + "planetscale_dedicated_pgbouncer_memory_usage", + "planetscale_pgbouncer_current_connections", + "planetscale_pgbouncer_pools_client", + "planetscale_pgbouncer_pools_server", + "planetscale_primary_postgres_connection_state", + "planetscale_replica_postgres_connection_state", + "planetscale_primary_pgbouncer_cpu_util_percentages", + "planetscale_primary_pgbouncer_mem_util_percentages", + "planetscale_replica_pgbouncer_current_connections", + "planetscale_replica_pgbouncer_cpu_util_percentages", + "planetscale_replica_pgbouncer_mem_util_percentages", + }, + }, + { + Name: "CPU, memory utilization, and IOPS", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_pods_cpu_util_percentages", + "planetscale_pods_mem_util_percentages", + "planetscale_pods_iops_total", + "planetscale_primary_pods_cpu_util_percentages", + "planetscale_primary_pods_mem_util_percentages", + "planetscale_primary_pods_iops_total", + "planetscale_replica_pods_cpu_util_percentages", + "planetscale_replica_pods_mem_util_percentages", + "planetscale_replica_pods_iops_total", + }, + }, + { + Name: "PostgreSQL memory composition", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_primary_memory_rss_bytes", + "planetscale_primary_memory_mmap_bytes", + "planetscale_primary_memory_active_cache_bytes", + "planetscale_primary_memory_inactive_cache_bytes", + "planetscale_replica_memory_rss_bytes", + "planetscale_replica_memory_mmap_bytes", + "planetscale_replica_memory_active_cache_bytes", + "planetscale_replica_memory_inactive_cache_bytes", + }, + }, + { + Name: "Storage utilization", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_primary_storage_usage", + "planetscale_replica_storage_usage_bytes", + "planetscale_storage_usage_bytes", + "planetscale_replica_volume_usage_percentages", + "planetscale_volume_usage_percentages", + }, + }, + { + Name: "Transactions, replication, and WAL", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_primary_xact_commit_rate", + "planetscale_replica_lag_seconds", + "planetscale_replication_slot_max_wal_retained_bytes", + "planetscale_replication_slots_lost", + "planetscale_settings_max_slot_wal_keep_size_bytes", + "planetscale_wal_archiver_succeeded_rate", + "planetscale_wal_archiver_failed_rate", + "planetscale_wal_archiver_last_age_succeeded", + "planetscale_wal_size_bytes", + }, + }, + { + Name: "Pod health", + Kind: reportSeriesSection, + Metrics: []string{ + "planetscale_pods_container_ooms", + }, + }, + { + Name: "Current connection capacity", + Kind: reportInstantSection, + Metrics: []string{ + "planetscale_dedicated_pgbouncer_current_connections", + "planetscale_dedicated_pgbouncer_current_client_connections", + "planetscale_dedicated_pgbouncer_current_server_connections", + "planetscale_dedicated_pgbouncer_max_connections", + "planetscale_dedicated_pgbouncer_cpu_usage", + "planetscale_dedicated_pgbouncer_memory_usage", + "planetscale_pgbouncer_current_client_connections", + "planetscale_pgbouncer_current_server_connections", + "planetscale_pgbouncer_settings_max_client_conn", + "planetscale_postgres_connection_state", + "planetscale_postgres_settings_max_connections", + }, + }, + { + Name: "Current storage capacity", + Kind: reportInstantSection, + Metrics: []string{ + "planetscale_volume_disk_usage_bytes", + "planetscale_volume_usage_percentage", + "planetscale_volume_capacity_bytes", + }, + }, + { + Name: "Backup activity", + Kind: reportInstantSection, + Metrics: []string{ + "planetscale_backup_restore_active", + "planetscale_backup_fetch_percent", + }, + }, +} + +func reportSectionsForEngine(engine ps.DatabaseEngine) ([]reportSectionDefinition, error) { + switch engine { + case ps.DatabaseEngineMySQL: + return mysqlReportSections, nil + case ps.DatabaseEnginePostgres: + return postgresReportSections, nil + default: + return nil, fmt.Errorf("database engine %q is not supported by metrics report", engine) + } +} diff --git a/internal/cmd/metrics/report_test.go b/internal/cmd/metrics/report_test.go new file mode 100644 index 00000000..198f8274 --- /dev/null +++ b/internal/cmd/metrics/report_test.go @@ -0,0 +1,164 @@ +package metrics + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/fatih/color" + qt "github.com/frankban/quicktest" + + "github.com/planetscale/cli/internal/mock" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +func reportClient(engine ps.DatabaseEngine, metrics *mock.MetricsService) *ps.Client { + return &ps.Client{ + Databases: &mock.DatabaseService{ + GetFn: func(context.Context, *ps.GetDatabaseRequest) (*ps.Database, error) { + return &ps.Database{Name: "mydb", Kind: engine}, nil + }, + }, + Metrics: metrics, + } +} + +func TestReportCmd_MySQLHumanUsesCuratedSections(t *testing.T) { + c := qt.New(t) + var requests []*ps.GetMetricSeriesRequest + service := &mock.MetricsService{ + GetSeriesFn: func(_ context.Context, req *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + requests = append(requests, req) + return sampleSeries(), nil + }, + } + + var buf bytes.Buffer + cmd := ReportCmd(metricsTestHelper(&buf, printer.Human, reportClient(ps.DatabaseEngineMySQL, service))) + cmd.SetArgs([]string{"mydb", "main", "--period", "1d"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(requests, qt.HasLen, len(mysqlReportSections)) + c.Assert(service.GetInstantFnInvoked, qt.IsFalse) + + for i, definition := range mysqlReportSections { + c.Assert(requests[i].Metrics, qt.DeepEquals, definition.Metrics) + c.Assert(requests[i].Period, qt.Equals, "1d") + c.Assert(buf.String(), qt.Contains, definition.Name) + } + c.Assert(buf.String(), qt.Contains, "Metrics report for planetscale/mydb/main") + c.Assert(buf.String(), qt.Not(qt.Contains), "(MySQL)") + c.Assert(buf.String(), qt.Not(qt.Contains), "##") +} + +func TestReportCmd_PostgresJSONIncludesSeriesAndInstantSections(t *testing.T) { + c := qt.New(t) + var seriesRequests []*ps.GetMetricSeriesRequest + var instantRequests []*ps.GetInstantMetricsRequest + service := &mock.MetricsService{ + GetSeriesFn: func(_ context.Context, req *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + seriesRequests = append(seriesRequests, req) + return sampleSeries(), nil + }, + GetInstantFn: func(_ context.Context, req *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) { + instantRequests = append(instantRequests, req) + return sampleInstantMetrics(), nil + }, + } + + var buf bytes.Buffer + cmd := ReportCmd(metricsTestHelper(&buf, printer.JSON, reportClient(ps.DatabaseEnginePostgres, service))) + cmd.SetArgs([]string{"mydb", "main", "--period", "7d", "--steps", "96"}) + c.Assert(cmd.Execute(), qt.IsNil) + + wantSeries, wantInstant := 0, 0 + for _, definition := range postgresReportSections { + switch definition.Kind { + case reportSeriesSection: + c.Assert(seriesRequests[wantSeries].Metrics, qt.DeepEquals, definition.Metrics) + c.Assert(seriesRequests[wantSeries].Steps, qt.Equals, 96) + wantSeries++ + case reportInstantSection: + c.Assert(instantRequests[wantInstant].Metrics, qt.DeepEquals, definition.Metrics) + wantInstant++ + } + } + c.Assert(seriesRequests, qt.HasLen, wantSeries) + c.Assert(instantRequests, qt.HasLen, wantInstant) + + var report metricsReport + c.Assert(json.Unmarshal(buf.Bytes(), &report), qt.IsNil) + c.Assert(report.Type, qt.Equals, "MetricsReport") + c.Assert(report.Organization, qt.Equals, "planetscale") + c.Assert(report.Engine, qt.Equals, ps.DatabaseEnginePostgres) + c.Assert(report.Period, qt.Equals, "7d") + c.Assert(report.Steps, qt.Equals, 96) + c.Assert(report.Sections, qt.HasLen, len(postgresReportSections)) + c.Assert(report.Sections[len(report.Sections)-1].Kind, qt.Equals, reportInstantSection) +} + +func TestReportCmd_CustomRangeReplacesPeriod(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetSeriesFn: func(_ context.Context, req *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + c.Assert(req.Period, qt.Equals, "") + c.Assert(req.From, qt.Equals, "2026-08-17T00:00:00Z") + c.Assert(req.To, qt.Equals, "2026-08-18T00:00:00Z") + return sampleSeries(), nil + }, + } + + cmd := ReportCmd(metricsTestHelper(&bytes.Buffer{}, printer.JSON, reportClient(ps.DatabaseEngineMySQL, service))) + cmd.SetArgs([]string{"mydb", "main", "--from", "2026-08-17T00:00:00Z", "--to", "2026-08-18T00:00:00Z"}) + c.Assert(cmd.Execute(), qt.IsNil) +} + +func TestReportCmd_CSVIncludesSection(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{ + GetSeriesFn: func(context.Context, *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + return sampleSeries(), nil + }, + } + + var buf bytes.Buffer + cmd := ReportCmd(metricsTestHelper(&buf, printer.CSV, reportClient(ps.DatabaseEngineMySQL, service))) + cmd.SetArgs([]string{"mydb", "main"}) + c.Assert(cmd.Execute(), qt.IsNil) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + c.Assert(lines[0], qt.Equals, "section,kind,timestamp,metric,series,dimensions,value") + c.Assert(lines[1], qt.Contains, "Workload, errors, and traffic control") +} + +func TestReportCmd_NoColorProducesPlainSectionHeadings(t *testing.T) { + c := qt.New(t) + oldNoColor := color.NoColor + color.NoColor = true + t.Cleanup(func() { color.NoColor = oldNoColor }) + + service := &mock.MetricsService{ + GetSeriesFn: func(context.Context, *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + return sampleSeries(), nil + }, + } + var buf bytes.Buffer + cmd := ReportCmd(metricsTestHelper(&buf, printer.Human, reportClient(ps.DatabaseEngineMySQL, service))) + cmd.SetArgs([]string{"mydb", "main"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(buf.String(), qt.Not(qt.Contains), "\x1b[") + c.Assert(buf.String(), qt.Not(qt.Contains), "##") + c.Assert(buf.String(), qt.Contains, "Latency and execution time\n") +} + +func TestReportCmd_RejectsUnsupportedEngineBeforeFetchingMetrics(t *testing.T) { + c := qt.New(t) + service := &mock.MetricsService{} + cmd := ReportCmd(metricsTestHelper(&bytes.Buffer{}, printer.JSON, reportClient(ps.DatabaseEngine("sqlite"), service))) + cmd.SetArgs([]string{"mydb", "main"}) + c.Assert(cmd.Execute(), qt.ErrorMatches, `database engine "sqlite" is not supported by metrics report`) + c.Assert(service.GetSeriesFnInvoked, qt.IsFalse) + c.Assert(service.GetInstantFnInvoked, qt.IsFalse) +} diff --git a/internal/cmd/metrics/show.go b/internal/cmd/metrics/show.go new file mode 100644 index 00000000..6defc8de --- /dev/null +++ b/internal/cmd/metrics/show.go @@ -0,0 +1,133 @@ +package metrics + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" + ps "github.com/planetscale/cli/internal/planetscale" + "github.com/planetscale/cli/internal/printer" +) + +// ShowCmd queries historical metric series for a branch. +func ShowCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + metrics []string + period string + from string + to string + steps int + tabletType string + keyspace string + shard string + role string + container string + pod string + pods []string + queryIDs []string + fingerprint string + budgetID string + ruleID string + search string + } + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show historical metric series", + Example: ` # Summarize query volume and p99 latency over the last hour + pscale metrics show mydb main --org myorg --metric queries --metric latency_p99 --period 1h + + # Export every sample as CSV + pscale metrics show mydb main --org myorg --metric queries --period 1h --format csv + + # Preserve the complete metrics API response + pscale metrics show mydb main --org myorg --metric queries --period 1h --format json`, + Args: cmdutil.RequiredArgs("database", "branch"), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRangeFlags(cmd, flags.from, flags.to); err != nil { + return err + } + if cmd.Flags().Changed("steps") && flags.steps <= 0 { + return fmt.Errorf("--steps must be greater than zero") + } + + client, err := ch.Client() + if err != nil { + return err + } + + database, branch := args[0], args[1] + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching metrics for %s in %s...", + printer.BoldBlue(branch), printer.BoldBlue(database))) + defer end() + + series, err := client.Metrics.GetSeries(cmd.Context(), &ps.GetMetricSeriesRequest{ + Organization: ch.Config.Organization, + Database: database, + Branch: branch, + Metrics: flags.metrics, + Period: flags.period, + From: flags.from, + To: flags.to, + Steps: flags.steps, + TabletType: flags.tabletType, + Keyspace: flags.keyspace, + Shard: flags.shard, + Role: flags.role, + Container: flags.container, + Pod: flags.pod, + Pods: flags.pods, + QueryIDs: flags.queryIDs, + Fingerprint: flags.fingerprint, + BudgetID: flags.budgetID, + RuleID: flags.ruleID, + Search: flags.search, + }) + if err != nil { + return cmdutil.HandleError(err) + } + end() + + switch ch.Printer.Format() { + case printer.JSON: + return ch.Printer.PrintJSON(series) + case printer.CSV: + return ch.Printer.PrintResource(metricPointRows(series)) + default: + return printSeriesSummary(ch, database, branch, series) + } + }, + } + + cmd.Flags().StringSliceVar(&flags.metrics, "metric", nil, "Metric to query (repeat or comma-separate)") + cmd.Flags().StringVar(&flags.period, "period", "", "Named time period to query (for example 1h, 12h, or 1d; defaults to 12h)") + cmd.Flags().StringVar(&flags.from, "from", "", "Start of a custom time range as an ISO 8601 timestamp") + cmd.Flags().StringVar(&flags.to, "to", "", "End of a custom time range as an ISO 8601 timestamp") + cmd.Flags().IntVar(&flags.steps, "steps", 0, "Requested number of data points") + cmd.Flags().StringVar(&flags.tabletType, "tablet-type", "", "Filter by tablet type") + cmd.Flags().StringVar(&flags.keyspace, "keyspace", "", "Filter by keyspace") + cmd.Flags().StringVar(&flags.shard, "shard", "", "Filter by shard") + cmd.Flags().StringVar(&flags.role, "role", "", "Filter by Postgres role") + cmd.Flags().StringVar(&flags.container, "container", "", "Filter by container") + cmd.Flags().StringVar(&flags.pod, "pod", "", "Filter by one pod") + cmd.Flags().StringSliceVar(&flags.pods, "pods", nil, "Filter by pods (repeat or comma-separate)") + cmd.Flags().StringSliceVar(&flags.queryIDs, "query-id", nil, "Filter by query pattern ID (repeat or comma-separate)") + cmd.Flags().StringVar(&flags.fingerprint, "fingerprint", "", "Filter by query fingerprint") + cmd.Flags().StringVar(&flags.budgetID, "budget-id", "", "Filter by traffic budget ID") + cmd.Flags().StringVar(&flags.ruleID, "rule-id", "", "Filter by traffic rule ID") + cmd.Flags().StringVarP(&flags.search, "search", "q", "", "Filter by search terms") + cmd.MarkFlagRequired("metric") // nolint:errcheck + + return cmd +} + +func validateRangeFlags(cmd *cobra.Command, from, to string) error { + if (from == "") != (to == "") { + return fmt.Errorf("--from and --to must be used together") + } + if from != "" && cmd.Flags().Changed("period") { + return fmt.Errorf("--period cannot be combined with --from and --to") + } + return nil +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 495e2bd0..f0ba7bc6 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -49,6 +49,7 @@ import ( "github.com/planetscale/cli/internal/cmd/inspect" "github.com/planetscale/cli/internal/cmd/keyspace" "github.com/planetscale/cli/internal/cmd/maintenance" + "github.com/planetscale/cli/internal/cmd/metrics" "github.com/planetscale/cli/internal/cmd/org" "github.com/planetscale/cli/internal/cmd/password" "github.com/planetscale/cli/internal/cmd/pgbouncer" @@ -339,6 +340,10 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. inspectCmd.GroupID = "database" rootCmd.AddCommand(inspectCmd) + metricsCmd := metrics.MetricsCmd(ch) + metricsCmd.GroupID = "database" + rootCmd.AddCommand(metricsCmd) + webhookCmd := webhook.WebhookCmd(ch) webhookCmd.GroupID = "database" rootCmd.AddCommand(webhookCmd) diff --git a/internal/mock/metrics.go b/internal/mock/metrics.go new file mode 100644 index 00000000..7616c881 --- /dev/null +++ b/internal/mock/metrics.go @@ -0,0 +1,25 @@ +package mock + +import ( + "context" + + ps "github.com/planetscale/cli/internal/planetscale" +) + +type MetricsService struct { + GetSeriesFn func(context.Context, *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) + GetSeriesFnInvoked bool + + GetInstantFn func(context.Context, *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) + GetInstantFnInvoked bool +} + +func (s *MetricsService) GetSeries(ctx context.Context, req *ps.GetMetricSeriesRequest) (*ps.MetricSeries, error) { + s.GetSeriesFnInvoked = true + return s.GetSeriesFn(ctx, req) +} + +func (s *MetricsService) GetInstant(ctx context.Context, req *ps.GetInstantMetricsRequest) (*ps.InstantMetrics, error) { + s.GetInstantFnInvoked = true + return s.GetInstantFn(ctx, req) +} diff --git a/internal/planetscale/client.go b/internal/planetscale/client.go index 06c62fc9..6a8c5f35 100644 --- a/internal/planetscale/client.go +++ b/internal/planetscale/client.go @@ -63,6 +63,7 @@ type Client struct { LookupVindex LookupVindexService MaintenanceSchedules MaintenanceSchedulesService Materialize MaterializeService + Metrics MetricsService MoveTables MoveTablesService Organizations OrganizationsService Passwords PasswordsService @@ -337,6 +338,7 @@ func NewClient(opts ...ClientOption) (*Client, error) { c.LookupVindex = &lookupVindexService{client: c} c.MaintenanceSchedules = &maintenanceSchedulesService{client: c} c.Materialize = &materializeService{client: c} + c.Metrics = &metricsService{client: c} c.MoveTables = &moveTablesService{client: c} c.Organizations = &organizationsService{client: c} c.Passwords = &passwordsService{client: c} diff --git a/internal/planetscale/metrics.go b/internal/planetscale/metrics.go new file mode 100644 index 00000000..11f46216 --- /dev/null +++ b/internal/planetscale/metrics.go @@ -0,0 +1,165 @@ +package planetscale + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strconv" + "time" +) + +// MetricsService provides access to branch time-series and instant metrics. +type MetricsService interface { + GetSeries(context.Context, *GetMetricSeriesRequest) (*MetricSeries, error) + GetInstant(context.Context, *GetInstantMetricsRequest) (*InstantMetrics, error) +} + +type metricsService struct { + client *Client +} + +var _ MetricsService = &metricsService{} + +// MetricSeries is a collection of sampled time series over a common range. +type MetricSeries struct { + Type string `json:"type"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + Interval int `json:"interval"` + Series []*TimeSeries `json:"series"` +} + +// TimeSeries contains samples for one metric and set of dimensions. +type TimeSeries struct { + Type string `json:"type"` + Metric string `json:"metric"` + Label string `json:"label"` + Labels map[string]string `json:"labels"` + Points [][]float64 `json:"points"` +} + +// InstantMetrics contains current metric values grouped by their dimensions. +type InstantMetrics struct { + Type string `json:"type"` + Branch map[string]any `json:"branch"` + Metrics []*InstantMetric `json:"metrics"` +} + +// InstantMetric contains the current values for one metric. +type InstantMetric struct { + Metric string `json:"metric"` + Label string `json:"label"` + Values []map[string]any `json:"values"` +} + +// GetMetricSeriesRequest describes a branch time-series metrics query. +type GetMetricSeriesRequest struct { + Organization string + Database string + Branch string + Metrics []string + Period string + From string + To string + Steps int + TabletType string + Keyspace string + Shard string + Role string + Container string + Pod string + Pods []string + QueryIDs []string + Fingerprint string + BudgetID string + RuleID string + Search string +} + +// GetInstantMetricsRequest describes a branch instant metrics query. +type GetInstantMetricsRequest struct { + Organization string + Database string + Branch string + Metrics []string + Role string + Shard string + Container string + Pod string +} + +func (s *metricsService) GetSeries(ctx context.Context, getReq *GetMetricSeriesRequest) (*MetricSeries, error) { + query := url.Values{} + addQueryValues(query, "metrics[]", getReq.Metrics) + setQueryValue(query, "period", getReq.Period) + setQueryValue(query, "from", getReq.From) + setQueryValue(query, "to", getReq.To) + if getReq.Steps > 0 { + query.Set("steps", strconv.Itoa(getReq.Steps)) + } + setQueryValue(query, "tablet_type", getReq.TabletType) + setQueryValue(query, "keyspace", getReq.Keyspace) + setQueryValue(query, "shard", getReq.Shard) + setQueryValue(query, "role", getReq.Role) + setQueryValue(query, "container", getReq.Container) + setQueryValue(query, "pod", getReq.Pod) + addQueryValues(query, "pods[]", getReq.Pods) + addQueryValues(query, "query_ids[]", getReq.QueryIDs) + setQueryValue(query, "fingerprint", getReq.Fingerprint) + setQueryValue(query, "budget_id", getReq.BudgetID) + setQueryValue(query, "rule_id", getReq.RuleID) + setQueryValue(query, "q", getReq.Search) + + req, err := s.client.newRequest(http.MethodGet, metricsAPIPath(getReq.Organization, getReq.Database, getReq.Branch), nil, WithQueryParams(query)) + if err != nil { + return nil, fmt.Errorf("error creating request for branch metrics: %w", err) + } + + series := &MetricSeries{} + if err := s.client.do(ctx, req, series); err != nil { + return nil, err + } + + return series, nil +} + +func (s *metricsService) GetInstant(ctx context.Context, getReq *GetInstantMetricsRequest) (*InstantMetrics, error) { + query := url.Values{} + addQueryValues(query, "metrics[]", getReq.Metrics) + setQueryValue(query, "role", getReq.Role) + setQueryValue(query, "shard", getReq.Shard) + setQueryValue(query, "container", getReq.Container) + setQueryValue(query, "pod", getReq.Pod) + + req, err := s.client.newRequest(http.MethodGet, path.Join(metricsAPIPath(getReq.Organization, getReq.Database, getReq.Branch), "instant"), nil, WithQueryParams(query)) + if err != nil { + return nil, fmt.Errorf("error creating request for instant branch metrics: %w", err) + } + + metrics := &InstantMetrics{} + if err := s.client.do(ctx, req, metrics); err != nil { + return nil, err + } + + return metrics, nil +} + +func metricsAPIPath(org, db, branch string) string { + return path.Join("v1/organizations", org, "databases", db, "branches", branch, "metrics") +} + +func setQueryValue(query url.Values, key, value string) { + if value != "" { + query.Set(key, value) + } +} + +func addQueryValues(query url.Values, key string, values []string) { + for _, value := range values { + if value != "" { + query.Add(key, value) + } + } +} diff --git a/internal/planetscale/metrics_test.go b/internal/planetscale/metrics_test.go new file mode 100644 index 00000000..8d36d840 --- /dev/null +++ b/internal/planetscale/metrics_test.go @@ -0,0 +1,104 @@ +package planetscale + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestMetrics_GetSeries(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/metrics") + query := r.URL.Query() + c.Assert(query["metrics[]"], qt.DeepEquals, []string{"queries", "latency_p99"}) + c.Assert(query.Get("period"), qt.Equals, "1h") + c.Assert(query.Get("steps"), qt.Equals, "60") + c.Assert(query.Get("tablet_type"), qt.Equals, "replica") + c.Assert(query["pods[]"], qt.DeepEquals, []string{"pod-1", "pod-2"}) + c.Assert(query["query_ids[]"], qt.DeepEquals, []string{"abc-main"}) + c.Assert(query.Get("q"), qt.Equals, "checkout") + + _, err := w.Write([]byte(`{ + "type":"MetricSeries", + "start_date":"2026-08-18T16:00:00Z", + "end_date":"2026-08-18T17:00:00Z", + "interval":60, + "series":[{ + "type":"TimeSeries", + "metric":"queries", + "label":"Queries", + "labels":{}, + "points":[[1787068800,912],[1787068860,1048]] + }] + }`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + series, err := client.Metrics.GetSeries(context.Background(), &GetMetricSeriesRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "main", + Metrics: []string{"queries", "latency_p99"}, + Period: "1h", + Steps: 60, + TabletType: "replica", + Pods: []string{"pod-1", "pod-2"}, + QueryIDs: []string{"abc-main"}, + Search: "checkout", + }) + c.Assert(err, qt.IsNil) + c.Assert(series.Type, qt.Equals, "MetricSeries") + c.Assert(series.Interval, qt.Equals, 60) + c.Assert(series.Series, qt.HasLen, 1) + c.Assert(series.Series[0].Points[1][1], qt.Equals, 1048.0) +} + +func TestMetrics_GetInstant(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c.Assert(r.Method, qt.Equals, http.MethodGet) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/my-db/branches/main/metrics/instant") + query := r.URL.Query() + c.Assert(query["metrics[]"], qt.DeepEquals, []string{"planetscale_volume_usage_percentage"}) + c.Assert(query.Get("role"), qt.Equals, "primary") + + _, err := w.Write([]byte(`{ + "type":"InstantMetrics", + "branch":{"id":"branch-id","name":"main"}, + "metrics":[{ + "metric":"planetscale_volume_usage_percentage", + "label":"volume_usage", + "values":[{"pod":"postgres-0","role":"primary","value":71.4}] + }] + }`)) + c.Assert(err, qt.IsNil) + })) + defer ts.Close() + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + metrics, err := client.Metrics.GetInstant(context.Background(), &GetInstantMetricsRequest{ + Organization: "my-org", + Database: "my-db", + Branch: "main", + Metrics: []string{"planetscale_volume_usage_percentage"}, + Role: "primary", + }) + c.Assert(err, qt.IsNil) + c.Assert(metrics.Type, qt.Equals, "InstantMetrics") + c.Assert(metrics.Branch["name"], qt.Equals, "main") + c.Assert(metrics.Metrics, qt.HasLen, 1) + c.Assert(metrics.Metrics[0].Values[0]["value"], qt.Equals, 71.4) +}