diff --git a/AGENTS.md b/AGENTS.md index d6ec6303..0a8406d1 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 (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 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..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) @@ -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 } diff --git a/internal/cmd/insights/errors_show.go b/internal/cmd/insights/errors_show.go new file mode 100644 index 00000000..d2d202b2 --- /dev/null +++ b/internal/cmd/insights/errors_show.go @@ -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 ", + 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 ' +(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"), + 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/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) { 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)