Skip to content
29 changes: 28 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ pscale sql <database> <branch> --org <org> --format json --keyspace <keyspace> -
| `--role` | `reader` (default), `writer`, `readwriter`, `admin` — same names as `pscale shell` |
| `--replica` | Route reads to replicas |
| `--dbname` | PostgreSQL database name (default `postgres`) |
| `--keyspace` | MySQL keyspace (default `@primary`) |
| `--keyspace` | MySQL keyspace (default `@primary`); may include a shard and tablet type: `mykeyspace/-80`, `mykeyspace/-80@replica` |
| `--force` | Allow destructive SQL after explicit user approval |

**`--role` by engine** (same as `pscale shell`):
Expand Down Expand Up @@ -197,6 +197,33 @@ 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).

## Diagnostics: insights + inspect

Two complementary read-only surfaces. When diagnosing database health or performance, **check both** — they see different things.

**`pscale insights`** — server-side analysis computed from production traffic (works even when you can't or don't want to connect to the database):

```bash
pscale insights queries <database> <branch> --org <org> --format json --sort totalTime # top queries; sorts: totalTime, count, p99Latency, rowsRead, rowsReadPerReturned, errorCount, ...
pscale insights errors <database> <branch> --org <org> --format json # failing queries with error messages
pscale insights anomalies <database> <branch> --org <org> --format json # detected resource anomalies (CPU, memory, IOPS, rows)
pscale insights recommendations <database> --org <org> --format json # schema recommendations with ready-to-apply DDL
```

**`pscale inspect`** — live, point-in-time checks run over a direct connection (same credentials model as `pscale sql`, always read-only):

```bash
pscale inspect all <database> <branch> --org <org> --format json # every applicable check, one report
pscale inspect <check> <database> <branch> --org <org> --format json
```

Checks: `table-sizes`, `index-sizes`, `unused-indexes`, `redundant-indexes`, `seq-scans`, `long-running-queries`, `locks`, `outliers`, `calls`, `bloat`, `vacuum-stats`, `replication-slots`, `subscriptions`. Checks adapt per engine; ones that don't apply explain the alternative. JSON results include `next_steps` pointing at the matching `insights` command — follow them.

Caveats:
- Statistics are since last server restart and per-connection-target: on sharded Vitess databases they reflect a single shard's MySQL instance. Use `--keyspace` to pick a keyspace, or pin an exact shard with `--keyspace 'mykeyspace/-80'` (enumerate with `pscale sql <database> <branch> --org <org> --format json --query "SHOW VITESS_SHARDS"`; rows are `keyspace/shard`). Databases can have hundreds of shards — inspect one shard at a time rather than fanning out. On PostgreSQL, stats are scoped to one database (use `--dbname`; if CONNECT is denied, retry with `--role admin`).
- `outliers`/`calls` need `pg_stat_statements` on PostgreSQL; if missing, use `pscale insights queries` instead (no extension needed).
- Rule of thumb: start with `insights` (traffic-aware, historical), use `inspect` for live state (locks, in-flight queries) and physical layout (sizes, bloat, index usage).

## Postgres branch changes (size, replicas, parameters)

`pscale branch resize` queues a single asynchronous **change request** for a Postgres branch covering cluster size, replica count, and configuration parameters in any combination. Track it with `resize status`; cancel it with `resize cancel` while queued.
Expand Down
80 changes: 80 additions & 0 deletions internal/cmd/insights/anomalies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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"
)

// AnomalyRow is an anomaly formatted for table output.
type AnomalyRow struct {
ID string `header:"id" json:"id"`
Active bool `header:"active" json:"active"`
PeriodStart string `header:"started" json:"period_start"`
PeriodEnd string `header:"ended" json:"period_end"`
MinutesInViolation int64 `header:"minutes in violation" json:"minutes_in_violation"`
}

// AnomaliesCmd lists detected resource anomalies for a branch.
func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "anomalies <database> <branch>",
Short: "List detected resource anomalies (CPU, memory, IOPS, rows read/written)",
Args: cmdutil.RequiredArgs("database", "branch"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, branch := args[0], args[1]

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

end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching anomalies for %s in %s...",
printer.BoldBlue(branch), printer.BoldBlue(database)))
defer end()

anomalies, err := client.QueryInsights.ListAnomalies(ctx, &ps.ListAnomaliesRequest{
Organization: ch.Config.Organization,
Database: database,
Branch: branch,
})
if err != nil {
return notFoundError(ch, err, database, branch)
}
end()

if len(anomalies) == 0 && ch.Printer.Format() == printer.Human {
ch.Printer.Printf("No anomalies detected for %s in %s.\n",
printer.BoldBlue(branch), printer.BoldBlue(database))
return nil
}

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

rows := make([]*AnomalyRow, 0, len(anomalies))
for _, a := range anomalies {
end := ""
if !a.PeriodEnd.IsZero() {
end = a.PeriodEnd.Format("2006-01-02 15:04")
}
rows = append(rows, &AnomalyRow{
ID: a.ID,
Active: a.Active,
PeriodStart: a.PeriodStart.Format("2006-01-02 15:04"),
PeriodEnd: end,
MinutesInViolation: a.MinutesInViolation,
})
}
return ch.Printer.PrintResource(rows)
},
}

return cmd
}
82 changes: 82 additions & 0 deletions internal/cmd/insights/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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"
)

// ErrorRow is a query error row formatted for table output.
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"`
}

// ErrorsCmd lists aggregated query errors for a branch.
func ErrorsCmd(ch *cmdutil.Helper) *cobra.Command {
var flags struct {
limit int
period string
}

cmd := &cobra.Command{
Use: "errors <database> <branch>",
Short: "List queries that are failing with errors",
Args: cmdutil.RequiredArgs("database", "branch"),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
database, branch := args[0], args[1]

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

end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching query errors for %s in %s...",
printer.BoldBlue(branch), printer.BoldBlue(database)))
defer end()

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

if len(errs) == 0 && ch.Printer.Format() == printer.Human {
ch.Printer.Printf("No query errors recorded for %s in %s.\n",
printer.BoldBlue(branch), printer.BoldBlue(database))
return nil
}

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

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,
})
}
return ch.Printer.PrintResource(rows)
},
}

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, 24h)")

return cmd
}
62 changes: 62 additions & 0 deletions internal/cmd/insights/insights.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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"
)

// InsightsCmd surfaces server-side query insights for a database: aggregated
// query statistics, query errors, anomalies, and schema recommendations.
func InsightsCmd(ch *cmdutil.Helper) *cobra.Command {
cmd := &cobra.Command{
Use: "insights <command>",
Short: "Query performance insights and schema recommendations for a database",
Long: `Surface PlanetScale's server-side analysis of a database: aggregated query
statistics (latency percentiles, rows read, errors), detected anomalies, and
schema recommendations, all computed from production traffic.

For live, connection-level diagnostics (table sizes, locks, running queries),
see pscale inspect.`,
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(QueriesCmd(ch))
cmd.AddCommand(ErrorsCmd(ch))
cmd.AddCommand(AnomaliesCmd(ch))
cmd.AddCommand(RecommendationsCmd(ch))

return cmd
}

// notFoundError maps a not-found API error to a message explaining both
// causes: a missing database/branch, or insights not being enabled.
func notFoundError(ch *cmdutil.Helper, err error, database, branch string) error {
switch cmdutil.ErrCode(err) {
case ps.ErrNotFound:
if branch != "" {
return fmt.Errorf("branch %s does not exist in database %s (organization: %s) or query insights is not enabled for the database",
printer.BoldBlue(branch), printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization))
}
return fmt.Errorf("database %s does not exist in organization %s",
printer.BoldBlue(database), printer.BoldBlue(ch.Config.Organization))
default:
return cmdutil.HandleError(err)
}
}

// truncate shortens s for table output.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-1] + "…"
}
Loading