From 5a52c3996edbd84bff997c338ebc853ee3edb3f6 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 09:57:55 -0500 Subject: [PATCH 1/2] Add pscale insights errors show and anomalies show The errors and anomalies lists surfaced identifiers with no way to drill into them. These commands fetch the queries behind an error fingerprint and an anomaly with its correlated queries. Co-authored-by: Cursor --- AGENTS.md | 2 + internal/cmd/insights/anomalies.go | 2 + internal/cmd/insights/anomalies_show.go | 98 ++++++++++++++++++++ internal/cmd/insights/anomalies_show_test.go | 87 +++++++++++++++++ internal/cmd/insights/errors.go | 2 + internal/cmd/insights/errors_show.go | 93 +++++++++++++++++++ internal/cmd/insights/errors_show_test.go | 82 ++++++++++++++++ internal/mock/insights.go | 16 ++++ internal/planetscale/insights.go | 79 ++++++++++++++-- internal/planetscale/insights_test.go | 89 ++++++++++++++++++ 10 files changed, 542 insertions(+), 8 deletions(-) create mode 100644 internal/cmd/insights/anomalies_show.go create mode 100644 internal/cmd/insights/anomalies_show_test.go create mode 100644 internal/cmd/insights/errors_show.go create mode 100644 internal/cmd/insights/errors_show_test.go diff --git a/AGENTS.md b/AGENTS.md index d6ec6303..18f318b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,9 @@ Two complementary read-only surfaces. When diagnosing database health or perform pscale insights queries --org --format json --sort totalTime # top queries; sorts: totalTime, count, p99Latency, rowsRead, rowsReadPerReturned, errorCount, ... pscale insights queries samples --org --format json --keyspace # recent executions; keyspace from queries list pscale insights errors --org --format json # failing queries with error messages +pscale insights errors show --org --format json # individual queries behind one error fingerprint pscale insights anomalies --org --format json # detected resource anomalies (CPU, memory, IOPS, rows) +pscale insights anomalies show --org --format json # one anomaly plus its correlated queries pscale insights tags --org --format json # query tag keys (sqlcommenter / system); use names with summaries pscale insights tags summaries --org --format json --tags username # stats grouped by tag; names match the Insights UI Key picker pscale insights recommendations --org --format json # schema recommendations with ready-to-apply DDL diff --git a/internal/cmd/insights/anomalies.go b/internal/cmd/insights/anomalies.go index 8d2e2007..76c3a67d 100644 --- a/internal/cmd/insights/anomalies.go +++ b/internal/cmd/insights/anomalies.go @@ -76,5 +76,7 @@ func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command { }, } + cmd.AddCommand(AnomaliesShowCmd(ch)) + return cmd } diff --git a/internal/cmd/insights/anomalies_show.go b/internal/cmd/insights/anomalies_show.go new file mode 100644 index 00000000..44ade505 --- /dev/null +++ b/internal/cmd/insights/anomalies_show.go @@ -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 ", + Short: "Show an anomaly and its correlated queries", + Long: `Show a single anomaly from 'pscale insights anomalies ', +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 +} diff --git a/internal/cmd/insights/anomalies_show_test.go b/internal/cmd/insights/anomalies_show_test.go new file mode 100644 index 00000000..0f7848cc --- /dev/null +++ b/internal/cmd/insights/anomalies_show_test.go @@ -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") +} diff --git a/internal/cmd/insights/errors.go b/internal/cmd/insights/errors.go index 49b29614..f26c27de 100644 --- a/internal/cmd/insights/errors.go +++ b/internal/cmd/insights/errors.go @@ -78,5 +78,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 } diff --git a/internal/cmd/insights/errors_show.go b/internal/cmd/insights/errors_show.go new file mode 100644 index 00000000..f6173120 --- /dev/null +++ b/internal/cmd/insights/errors_show.go @@ -0,0 +1,93 @@ +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 ", + Short: "Show the queries behind an error fingerprint", + Long: `Show the individual query executions that failed with an error fingerprint +from 'pscale insights errors '. + +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"), + 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 +} diff --git a/internal/cmd/insights/errors_show_test.go b/internal/cmd/insights/errors_show_test.go new file mode 100644 index 00000000..70dd3c49 --- /dev/null +++ b/internal/cmd/insights/errors_show_test.go @@ -0,0 +1,82 @@ +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_ErrorsShowCmd(t *testing.T) { + c := qt.New(t) + + svc := &mock.QueryInsightsService{ + ListErrorQueriesFn: func(ctx context.Context, req *ps.ListErrorQueriesRequest, opts ...ps.ListOption) ([]*ps.QuerySample, 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.Fingerprint, qt.Equals, "b129e8fa") + return []*ps.QuerySample{{ + ID: "exec-1", + NormalizedSQL: "select * from users where id = ?", + Keyspace: "main", + Username: "app", + ErrorMessage: "vttablet: rpc error", + StartedAt: time.Date(2026, 8, 11, 18, 0, 0, 0, time.UTC), + }}, nil + }, + } + + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc}) + + cmd := ErrorsShowCmd(ch) + cmd.SetArgs([]string{"mydb", "main", "b129e8fa"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNil) + c.Assert(svc.ListErrorQueriesFnInvoked, qt.IsTrue) + + var out []map[string]any + c.Assert(json.Unmarshal(buf.Bytes(), &out), qt.IsNil) + c.Assert(out, qt.HasLen, 1) + c.Assert(out[0]["error_message"], qt.Equals, "vttablet: rpc error") +} + +func TestInsights_ErrorsShowCmd_NotFound(t *testing.T) { + c := qt.New(t) + + svc := &mock.QueryInsightsService{ + ListErrorQueriesFn: func(ctx context.Context, req *ps.ListErrorQueriesRequest, opts ...ps.ListOption) ([]*ps.QuerySample, error) { + return nil, &ps.Error{Code: ps.ErrNotFound} + }, + } + + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc}) + + cmd := ErrorsShowCmd(ch) + cmd.SetArgs([]string{"mydb", "main", "b129e8fa"}) + err := cmd.Execute() + + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, "does not exist") +} + +func TestInsights_ErrorsCmd_HasShowSubcommand(t *testing.T) { + c := qt.New(t) + + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{}) + + cmd, _, err := ErrorsCmd(ch).Find([]string{"show"}) + c.Assert(err, qt.IsNil) + c.Assert(cmd.Name(), qt.Equals, "show") +} diff --git a/internal/mock/insights.go b/internal/mock/insights.go index 112ab27c..ca3eb16b 100644 --- a/internal/mock/insights.go +++ b/internal/mock/insights.go @@ -16,9 +16,15 @@ type QueryInsightsService struct { ListErrorsFn func(context.Context, *ps.ListQueryInsightsErrorsRequest, ...ps.ListOption) ([]*ps.QueryInsightError, error) ListErrorsFnInvoked bool + ListErrorQueriesFn func(context.Context, *ps.ListErrorQueriesRequest, ...ps.ListOption) ([]*ps.QuerySample, error) + ListErrorQueriesFnInvoked bool + ListAnomaliesFn func(context.Context, *ps.ListAnomaliesRequest, ...ps.ListOption) ([]*ps.Anomaly, error) ListAnomaliesFnInvoked bool + GetAnomalyFn func(context.Context, *ps.GetAnomalyRequest) (*ps.Anomaly, error) + GetAnomalyFnInvoked bool + ListTagsFn func(context.Context, *ps.ListQueryTagsRequest, ...ps.ListOption) ([]*ps.QueryTag, error) ListTagsFnInvoked bool @@ -34,6 +40,16 @@ func (s *QueryInsightsService) ListQueries(ctx context.Context, req *ps.ListQuer return s.ListQueriesFn(ctx, req, opts...) } +func (s *QueryInsightsService) ListErrorQueries(ctx context.Context, req *ps.ListErrorQueriesRequest, opts ...ps.ListOption) ([]*ps.QuerySample, error) { + s.ListErrorQueriesFnInvoked = true + return s.ListErrorQueriesFn(ctx, req, opts...) +} + +func (s *QueryInsightsService) GetAnomaly(ctx context.Context, req *ps.GetAnomalyRequest) (*ps.Anomaly, error) { + s.GetAnomalyFnInvoked = true + return s.GetAnomalyFn(ctx, req) +} + func (s *QueryInsightsService) ListQuerySamples(ctx context.Context, req *ps.ListQuerySamplesRequest, opts ...ps.ListOption) ([]*ps.QuerySample, error) { s.ListQuerySamplesFnInvoked = true return s.ListQuerySamplesFn(ctx, req, opts...) diff --git a/internal/planetscale/insights.go b/internal/planetscale/insights.go index 4b3d62e8..4e56420a 100644 --- a/internal/planetscale/insights.go +++ b/internal/planetscale/insights.go @@ -16,7 +16,9 @@ type QueryInsightsService interface { ListQueries(context.Context, *ListQueryInsightsRequest, ...ListOption) ([]*QueryInsight, error) ListQuerySamples(context.Context, *ListQuerySamplesRequest, ...ListOption) ([]*QuerySample, error) ListErrors(context.Context, *ListQueryInsightsErrorsRequest, ...ListOption) ([]*QueryInsightError, error) + ListErrorQueries(context.Context, *ListErrorQueriesRequest, ...ListOption) ([]*QuerySample, error) ListAnomalies(context.Context, *ListAnomaliesRequest, ...ListOption) ([]*Anomaly, error) + GetAnomaly(context.Context, *GetAnomalyRequest) (*Anomaly, error) ListTags(context.Context, *ListQueryTagsRequest, ...ListOption) ([]*QueryTag, error) GetTag(context.Context, *GetQueryTagRequest, ...ListOption) (*QueryTag, error) ListTagSummaries(context.Context, *ListTagSummariesRequest, ...ListOption) ([]*TagSummary, error) @@ -71,15 +73,27 @@ type QueryInsightError struct { } // Anomaly is a detected resource-usage anomaly on a branch's primary. +// Correlations are only returned when retrieving a single anomaly. type Anomaly struct { - ID string `json:"id"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - MinutesInViolation int64 `json:"minutes_in_violation"` - Active bool `json:"active"` - Duration float64 `json:"duration"` - MetricsStart time.Time `json:"metrics_start"` - MetricsEnd time.Time `json:"metrics_end"` + ID string `json:"id"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + MinutesInViolation int64 `json:"minutes_in_violation"` + Active bool `json:"active"` + Duration float64 `json:"duration"` + MetricsStart time.Time `json:"metrics_start"` + MetricsEnd time.Time `json:"metrics_end"` + Correlations []Correlation `json:"correlations"` +} + +// Correlation is a query correlated with an anomaly. +type Correlation struct { + ID string `json:"id"` + R float64 `json:"r"` + Keyspace string `json:"keyspace"` + Fingerprint string `json:"fingerprint"` + NormalizedSQL string `json:"normalized_sql"` + TabletType string `json:"tablet_type"` } // ListQueryInsightsRequest is the request for listing query statistics for a branch. @@ -96,6 +110,15 @@ type ListQueryInsightsErrorsRequest struct { Branch string } +// ListErrorQueriesRequest is the request for listing the individual queries +// that failed with a given error fingerprint. +type ListErrorQueriesRequest struct { + Organization string + Database string + Branch string + Fingerprint string +} + // ListAnomaliesRequest is the request for listing anomalies for a branch. type ListAnomaliesRequest struct { Organization string @@ -103,6 +126,14 @@ type ListAnomaliesRequest struct { Branch string } +// GetAnomalyRequest is the request for retrieving a single anomaly. +type GetAnomalyRequest struct { + Organization string + Database string + Branch string + AnomalyID string +} + // QuerySampleTag is a name/value tag attached to an individual query execution. type QuerySampleTag struct { Name string `json:"name"` @@ -208,6 +239,23 @@ func (s *queryInsightsService) ListErrors(ctx context.Context, request *ListQuer return resp.Errors, nil } +func (s *queryInsightsService) ListErrorQueries(ctx context.Context, request *ListErrorQueriesRequest, opts ...ListOption) ([]*QuerySample, error) { + listOpts := defaultListOptions(opts...) + + pathStr := path.Join(insightsAPIPath(request.Organization, request.Database, request.Branch), "errors", request.Fingerprint) + req, err := s.client.newRequest(http.MethodGet, pathStr, nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + resp := &querySamplesResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + func (s *queryInsightsService) ListAnomalies(ctx context.Context, request *ListAnomaliesRequest, opts ...ListOption) ([]*Anomaly, error) { listOpts := defaultListOptions(opts...) @@ -224,6 +272,21 @@ func (s *queryInsightsService) ListAnomalies(ctx context.Context, request *ListA return resp.Anomalies, nil } +func (s *queryInsightsService) GetAnomaly(ctx context.Context, request *GetAnomalyRequest) (*Anomaly, error) { + pathStr := path.Join(insightsAPIPath(request.Organization, request.Database, request.Branch), "anomalies", request.AnomalyID) + req, err := s.client.newRequest(http.MethodGet, pathStr, nil) + if err != nil { + return nil, err + } + + anomaly := &Anomaly{} + if err := s.client.do(ctx, req, anomaly); err != nil { + return nil, err + } + + return anomaly, nil +} + type querySamplesResponse struct { Data []*QuerySample `json:"data"` } diff --git a/internal/planetscale/insights_test.go b/internal/planetscale/insights_test.go index b9643e8d..f1f6abb2 100644 --- a/internal/planetscale/insights_test.go +++ b/internal/planetscale/insights_test.go @@ -167,6 +167,95 @@ func TestQueryInsights_ListAnomalies(t *testing.T) { c.Assert(anomalies[0].Duration, qt.Equals, 1800.0) } +func TestQueryInsights_ListErrorQueries(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/planetscale-go-test-db/branches/main/insights/errors/b129e8fa") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "10") + c.Assert(r.URL.Query().Get("period"), qt.Equals, "1h") + + w.WriteHeader(200) + out := `{ + "type": "list", + "data": [{ + "id": "exec-1", + "fingerprint": "b129e8fa", + "normalized_sql": "select * from users where id = ?", + "keyspace": "main", + "username": "app", + "total_duration_millis": 2.5, + "started_at": "2026-08-11T18:00:00.000Z", + "error_message": "target: main.-.primary: vttablet: rpc error" + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + queries, err := client.QueryInsights.ListErrorQueries(context.Background(), &ListErrorQueriesRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + Fingerprint: "b129e8fa", + }, WithPerPage(10), WithPeriod("1h")) + + c.Assert(err, qt.IsNil) + c.Assert(queries, qt.HasLen, 1) + c.Assert(queries[0].ID, qt.Equals, "exec-1") + c.Assert(queries[0].Keyspace, qt.Equals, "main") + c.Assert(queries[0].ErrorMessage, qt.Equals, "target: main.-.primary: vttablet: rpc error") +} + +func TestQueryInsights_GetAnomaly(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/planetscale-go-test-db/branches/main/insights/anomalies/anomaly-123") + + w.WriteHeader(200) + out := `{ + "id": "anomaly-123", + "period_start": "2026-08-11T18:00:00.000Z", + "period_end": "2026-08-11T18:30:00.000Z", + "minutes_in_violation": 12, + "active": false, + "duration": 1800.0, + "correlations": [{ + "id": "corr-1", + "r": 0.94, + "keyspace": "main", + "fingerprint": "b129e8fa", + "normalized_sql": "select * from users where id = ?", + "tablet_type": "primary" + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + anomaly, err := client.QueryInsights.GetAnomaly(context.Background(), &GetAnomalyRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + AnomalyID: "anomaly-123", + }) + + c.Assert(err, qt.IsNil) + c.Assert(anomaly.ID, qt.Equals, "anomaly-123") + c.Assert(anomaly.Correlations, qt.HasLen, 1) + c.Assert(anomaly.Correlations[0].Fingerprint, qt.Equals, "b129e8fa") + c.Assert(anomaly.Correlations[0].R, qt.Equals, 0.94) +} + func TestQueryInsights_ListQuerySamples(t *testing.T) { c := qt.New(t) From f4e6cf1ed2ee1c0945b1103ef7a3ba57d16851ce Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Wed, 19 Aug 2026 10:03:33 -0500 Subject: [PATCH 2/2] Print the full error fingerprint in insights errors The errors table showed the truncated id, which the errors show lookup rejects because the API matches error_fingerprint exactly. Co-authored-by: Cursor --- AGENTS.md | 2 +- internal/cmd/insights/errors.go | 26 ++++++++++++++++---------- internal/cmd/insights/errors_show.go | 6 ++++-- internal/cmd/insights/queries_test.go | 11 +++++++---- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 18f318b3..0a8406d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,7 +242,7 @@ Two complementary read-only surfaces. When diagnosing database health or perform pscale insights queries --org --format json --sort totalTime # top queries; sorts: totalTime, count, p99Latency, rowsRead, rowsReadPerReturned, errorCount, ... pscale insights queries samples --org --format json --keyspace # recent executions; keyspace from queries list pscale insights errors --org --format json # failing queries with error messages -pscale insights errors show --org --format json # individual queries behind one error fingerprint +pscale insights errors show --org --format json # individual queries behind one error fingerprint (use error_fingerprint from the errors list) pscale insights anomalies --org --format json # detected resource anomalies (CPU, memory, IOPS, rows) pscale insights anomalies show --org --format json # one anomaly plus its correlated queries pscale insights tags --org --format json # query tag keys (sqlcommenter / system); use names with summaries diff --git a/internal/cmd/insights/errors.go b/internal/cmd/insights/errors.go index f26c27de..68701f8d 100644 --- a/internal/cmd/insights/errors.go +++ b/internal/cmd/insights/errors.go @@ -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. @@ -28,7 +30,11 @@ func ErrorsCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ Use: "errors ", 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] @@ -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) diff --git a/internal/cmd/insights/errors_show.go b/internal/cmd/insights/errors_show.go index f6173120..d2d202b2 100644 --- a/internal/cmd/insights/errors_show.go +++ b/internal/cmd/insights/errors_show.go @@ -30,8 +30,10 @@ func ErrorsShowCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ Use: "show ", Short: "Show the queries behind an error fingerprint", - Long: `Show the individual query executions that failed with an error fingerprint -from 'pscale insights errors '. + Long: `Show the individual query executions that failed with an error fingerprint. + +Use the fingerprint column from 'pscale insights errors ' +(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 diff --git a/internal/cmd/insights/queries_test.go b/internal/cmd/insights/queries_test.go index ccda95a0..cd4efa50 100644 --- a/internal/cmd/insights/queries_test.go +++ b/internal/cmd/insights/queries_test.go @@ -84,10 +84,11 @@ func TestInsights_ErrorsCmd(t *testing.T) { svc := &mock.QueryInsightsService{ ListErrorsFn: func(ctx context.Context, req *ps.ListQueryInsightsErrorsRequest, opts ...ps.ListOption) ([]*ps.QueryInsightError, error) { return []*ps.QueryInsightError{{ - ID: "e1", - ErrorCount: 4, - ErrorMessage: "relation \"widgets\" does not exist", - StartedAt: time.Date(2026, 7, 22, 14, 5, 0, 0, time.UTC), + ID: "e1", + ErrorFingerprint: "e1f4c9a2b7d3e1f4c9a2b7d3", + ErrorCount: 4, + ErrorMessage: "relation \"widgets\" does not exist", + StartedAt: time.Date(2026, 7, 22, 14, 5, 0, 0, time.UTC), }}, nil }, } @@ -102,6 +103,8 @@ func TestInsights_ErrorsCmd(t *testing.T) { c.Assert(err, qt.IsNil) c.Assert(svc.ListErrorsFnInvoked, qt.IsTrue) c.Assert(buf.String(), qt.Contains, "relation") + // The full fingerprint is what 'errors show' takes, so it must be printed. + c.Assert(buf.String(), qt.Contains, "e1f4c9a2b7d3e1f4c9a2b7d3") } func TestInsights_AnomaliesCmd(t *testing.T) {