Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,9 @@ Two complementary read-only surfaces. When diagnosing database health or perform
pscale insights queries <database> <branch> --org <org> --format json --sort totalTime # top queries; sorts: totalTime, count, p99Latency, rowsRead, rowsReadPerReturned, errorCount, ...
pscale insights queries samples <database> <branch> <fingerprint> --org <org> --format json --keyspace <keyspace> # recent executions; keyspace from queries list
pscale insights errors <database> <branch> --org <org> --format json # failing queries with error messages
pscale insights errors show <database> <branch> <fingerprint> --org <org> --format json # individual queries behind one error fingerprint (use error_fingerprint from the errors list)
pscale insights anomalies <database> <branch> --org <org> --format json # detected resource anomalies (CPU, memory, IOPS, rows)
pscale insights anomalies show <database> <branch> <id> --org <org> --format json # one anomaly plus its correlated queries
pscale insights tags <database> <branch> --org <org> --format json # query tag keys (sqlcommenter / system); use names with summaries
pscale insights tags summaries <database> <branch> --org <org> --format json --tags username # stats grouped by tag; names match the Insights UI Key picker
pscale insights recommendations <database> --org <org> --format json # schema recommendations with ready-to-apply DDL
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/insights/anomalies.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,7 @@ func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command {
},
}

cmd.AddCommand(AnomaliesShowCmd(ch))

return cmd
}
98 changes: 98 additions & 0 deletions internal/cmd/insights/anomalies_show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package insights

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"
)

// CorrelationRow is a query correlated with an anomaly, for table output.
type CorrelationRow struct {
Correlation float64 `header:"correlation" json:"r"`
Fingerprint string `header:"fingerprint" json:"fingerprint"`
Keyspace string `header:"keyspace" json:"keyspace"`
TabletType string `header:"tablet type" json:"tablet_type"`
Query string `header:"query" json:"normalized_sql"`
}

// AnomaliesShowCmd shows an anomaly and the queries correlated with it.
func AnomaliesShowCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "show <database> <branch> <anomaly-id>",
Short: "Show an anomaly and its correlated queries",
Long: `Show a single anomaly from 'pscale insights anomalies <database> <branch>',
along with the queries whose activity correlates with it.`,
Example: ` pscale insights anomalies show mydb main anomaly-id --org myorg`,
Args: cmdutil.RequiredArgs("database", "branch", "anomaly-id"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, branch, anomalyID := args[0], args[1], args[2]

client, err := ch.Client()
if err != nil {
return err
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching anomaly %s on %s/%s...",
printer.BoldBlue(anomalyID), printer.BoldBlue(database), printer.BoldBlue(branch)))
defer end()

anomaly, err := client.QueryInsights.GetAnomaly(ctx, &ps.GetAnomalyRequest{
Organization: ch.Config.Organization,
Database: database,
Branch: branch,
AnomalyID: anomalyID,
})
if err != nil {
return notFoundError(ch, err, database, branch)
}
end()

if ch.Printer.Format() == printer.JSON {
return ch.Printer.PrintJSON(anomaly)
}

periodEnd := ""
if !anomaly.PeriodEnd.IsZero() {
periodEnd = anomaly.PeriodEnd.Format("2006-01-02 15:04")
}
if err := ch.Printer.PrintResource([]*AnomalyRow{{
ID: anomaly.ID,
Active: anomaly.Active,
PeriodStart: anomaly.PeriodStart.Format("2006-01-02 15:04"),
PeriodEnd: periodEnd,
MinutesInViolation: anomaly.MinutesInViolation,
}}); err != nil {
return err
}

if ch.Printer.Format() != printer.Human {
return nil
}

if len(anomaly.Correlations) == 0 {
ch.Printer.Println("\nNo correlated queries for this anomaly.")
return nil
}

ch.Printer.Printf("\n%s\n", printer.Bold("Correlated queries:"))
rows := make([]*CorrelationRow, 0, len(anomaly.Correlations))
for _, c := range anomaly.Correlations {
rows = append(rows, &CorrelationRow{
Correlation: round2(c.R),
Fingerprint: c.Fingerprint,
Keyspace: c.Keyspace,
TabletType: c.TabletType,
Query: truncate(c.NormalizedSQL, 60),
})
}
return ch.Printer.PrintResource(rows)
},
}

return cmd
}
87 changes: 87 additions & 0 deletions internal/cmd/insights/anomalies_show_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package insights

import (
"bytes"
"context"
"encoding/json"
"testing"
"time"

"github.com/planetscale/cli/internal/mock"
ps "github.com/planetscale/cli/internal/planetscale"
"github.com/planetscale/cli/internal/printer"

qt "github.com/frankban/quicktest"
)

func TestInsights_AnomaliesShowCmd(t *testing.T) {
c := qt.New(t)

svc := &mock.QueryInsightsService{
GetAnomalyFn: func(ctx context.Context, req *ps.GetAnomalyRequest) (*ps.Anomaly, 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.AnomalyID, qt.Equals, "anomaly-123")
return &ps.Anomaly{
ID: "anomaly-123",
PeriodStart: time.Date(2026, 8, 11, 18, 0, 0, 0, time.UTC),
MinutesInViolation: 12,
Correlations: []ps.Correlation{{
ID: "corr-1",
R: 0.94,
Fingerprint: "b129e8fa",
Keyspace: "main",
NormalizedSQL: "select * from users where id = ?",
TabletType: "primary",
}},
}, nil
},
}

var buf bytes.Buffer
ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc})

cmd := AnomaliesShowCmd(ch)
cmd.SetArgs([]string{"mydb", "main", "anomaly-123"})
err := cmd.Execute()

c.Assert(err, qt.IsNil)
c.Assert(svc.GetAnomalyFnInvoked, qt.IsTrue)

var out map[string]any
c.Assert(json.Unmarshal(buf.Bytes(), &out), qt.IsNil)
c.Assert(out["id"], qt.Equals, "anomaly-123")
c.Assert(out["correlations"], qt.HasLen, 1)
}

func TestInsights_AnomaliesShowCmd_NotFound(t *testing.T) {
c := qt.New(t)

svc := &mock.QueryInsightsService{
GetAnomalyFn: func(ctx context.Context, req *ps.GetAnomalyRequest) (*ps.Anomaly, error) {
return nil, &ps.Error{Code: ps.ErrNotFound}
},
}

var buf bytes.Buffer
ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc})

cmd := AnomaliesShowCmd(ch)
cmd.SetArgs([]string{"mydb", "main", "anomaly-123"})
err := cmd.Execute()

c.Assert(err, qt.IsNotNil)
c.Assert(err.Error(), qt.Contains, "does not exist")
}

func TestInsights_AnomaliesCmd_HasShowSubcommand(t *testing.T) {
c := qt.New(t)

var buf bytes.Buffer
ch := testHelper(&buf, printer.JSON, &ps.Client{})

cmd, _, err := AnomaliesCmd(ch).Find([]string{"show"})
c.Assert(err, qt.IsNil)
c.Assert(cmd.Name(), qt.Equals, "show")
}
28 changes: 18 additions & 10 deletions internal/cmd/insights/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import (
"github.com/planetscale/cli/internal/printer"
)

// ErrorRow is a query error row formatted for table output.
// ErrorRow is a query error row formatted for table output. The fingerprint is
// the full error_fingerprint rather than the truncated id, so it can be passed
// to 'pscale insights errors show'.
type ErrorRow struct {
Count int64 `header:"count" json:"error_count"`
LastSeen string `header:"last seen" json:"started_at"`
Message string `header:"message" json:"error_message"`
ID string `header:"id" json:"id"`
Count int64 `header:"count" json:"error_count"`
LastSeen string `header:"last seen" json:"started_at"`
Message string `header:"message" json:"error_message"`
Fingerprint string `header:"fingerprint" json:"error_fingerprint"`
}

// ErrorsCmd lists aggregated query errors for a branch.
Expand All @@ -28,7 +30,11 @@ func ErrorsCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "errors <database> <branch>",
Short: "List queries that are failing with errors",
Args: cmdutil.RequiredArgs("database", "branch"),
Long: `List aggregated query errors for a branch.

Pass a value from the fingerprint column to 'pscale insights errors show' to
see the individual queries behind an error.`,
Args: cmdutil.RequiredArgs("database", "branch"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, branch := args[0], args[1]
Expand Down Expand Up @@ -65,10 +71,10 @@ func ErrorsCmd(ch *cmdutil.Helper) *cobra.Command {
rows := make([]*ErrorRow, 0, len(errs))
for _, e := range errs {
rows = append(rows, &ErrorRow{
Count: e.ErrorCount,
LastSeen: e.StartedAt.Format("2006-01-02 15:04"),
Message: truncate(e.ErrorMessage, 100),
ID: e.ID,
Count: e.ErrorCount,
LastSeen: e.StartedAt.Format("2006-01-02 15:04"),
Message: truncate(e.ErrorMessage, 100),
Fingerprint: e.ErrorFingerprint,
})
}
return ch.Printer.PrintResource(rows)
Expand All @@ -78,5 +84,7 @@ func ErrorsCmd(ch *cmdutil.Helper) *cobra.Command {
cmd.Flags().IntVar(&flags.limit, "limit", 15, "Number of errors to return")
cmd.Flags().StringVar(&flags.period, "period", "", "Time period to aggregate over (e.g. 1h, 1d)")

cmd.AddCommand(ErrorsShowCmd(ch))

return cmd
}
95 changes: 95 additions & 0 deletions internal/cmd/insights/errors_show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package insights

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"
)

// ErrorQueryRow is a failed query execution formatted for table output.
type ErrorQueryRow struct {
StartedAt string `header:"started" json:"started_at"`
DurationMs float64 `header:"duration (ms)" json:"total_duration_millis"`
Username string `header:"user" json:"username"`
Keyspace string `header:"keyspace" json:"keyspace"`
Error string `header:"error" json:"error_message"`
Query string `header:"query" json:"normalized_sql"`
}

// ErrorsShowCmd lists the individual queries behind an error fingerprint.
func ErrorsShowCmd(ch *cmdutil.Helper) *cobra.Command {
var flags struct {
limit int
period string
}

cmd := &cobra.Command{
Use: "show <database> <branch> <fingerprint>",
Short: "Show the queries behind an error fingerprint",
Long: `Show the individual query executions that failed with an error fingerprint.

Use the fingerprint column from 'pscale insights errors <database> <branch>'
(error_fingerprint in JSON), not the truncated id.

Useful for seeing which users, keyspaces, and statements produced the error.`,
Example: ` pscale insights errors show mydb main b129e8fa --org myorg
pscale insights errors show mydb main b129e8fa --org myorg --period 1h --limit 50`,
Args: cmdutil.RequiredArgs("database", "branch", "fingerprint"),
Comment thread
cursor[bot] marked this conversation as resolved.
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, branch, fingerprint := args[0], args[1], args[2]

client, err := ch.Client()
if err != nil {
return err
}

end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching queries for error fingerprint %s on %s/%s...",
printer.BoldBlue(fingerprint), printer.BoldBlue(database), printer.BoldBlue(branch)))
defer end()

queries, err := client.QueryInsights.ListErrorQueries(ctx, &ps.ListErrorQueriesRequest{
Organization: ch.Config.Organization,
Database: database,
Branch: branch,
Fingerprint: fingerprint,
}, ps.WithPerPage(flags.limit), ps.WithPeriod(flags.period))
if err != nil {
return notFoundError(ch, err, database, branch)
}
end()

if len(queries) == 0 && ch.Printer.Format() == printer.Human {
ch.Printer.Printf("No queries recorded for error fingerprint %s on %s/%s.\n",
printer.BoldBlue(fingerprint), printer.BoldBlue(database), printer.BoldBlue(branch))
return nil
}

if ch.Printer.Format() == printer.JSON {
return ch.Printer.PrintJSON(queries)
}

rows := make([]*ErrorQueryRow, 0, len(queries))
for _, q := range queries {
rows = append(rows, &ErrorQueryRow{
StartedAt: q.StartedAt.Format("2006-01-02 15:04:05"),
DurationMs: round2(q.TotalDurationMillis),
Username: q.Username,
Keyspace: q.Keyspace,
Error: truncate(q.ErrorMessage, 60),
Query: truncate(q.NormalizedSQL, 60),
})
}
return ch.Printer.PrintResource(rows)
},
}

cmd.Flags().IntVar(&flags.limit, "limit", 25, "Number of queries to return")
cmd.Flags().StringVar(&flags.period, "period", "", "Time period to look back (e.g. 1h, 1d)")

return cmd
}
Loading