Skip to content
Draft
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
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