From 1b74d3765f192a1d8eb716fc18e1d9b7a3a1849b Mon Sep 17 00:00:00 2001 From: Christian Glassiognon <63924603+heyglassy@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:59:23 -0700 Subject: [PATCH] Add anomaly detail and time filters --- AGENTS.md | 6 +- internal/cmd/insights/anomalies.go | 183 +++++++++++++++++++++++++- internal/cmd/insights/queries_test.go | 178 ++++++++++++++++++++++++- internal/mock/insights.go | 7 + internal/planetscale/insights.go | 70 ++++++++-- internal/planetscale/insights_test.go | 53 +++++++- 6 files changed, 482 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f91e5ba2..45ee6b02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,7 +206,11 @@ Two complementary read-only surfaces. When diagnosing database health or perform ```bash pscale insights queries --org --format json --sort totalTime # top queries; sorts: totalTime, count, p99Latency, rowsRead, rowsReadPerReturned, errorCount, ... pscale insights errors --org --format json # failing queries with error messages -pscale insights anomalies --org --format json # detected resource anomalies (CPU, memory, IOPS, rows) +pscale insights anomalies --org --format json # detected resource anomalies (default: last day) +pscale insights anomalies --org --format json --period 1d # named range: 15m, 1h, 3h, 6h, 12h, 1d, 2d, 7d, 8d +pscale insights anomalies --org --format json --from 07/23 --to 07/25 # local dates; current year inferred and the full end date included +pscale insights anomalies --org --format json --from --to # exact timestamps; --to defaults to now +pscale insights anomalies --org --format json # one anomaly with correlated query fingerprints and SQL 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..80a189f0 100644 --- a/internal/cmd/insights/anomalies.go +++ b/internal/cmd/insights/anomalies.go @@ -1,7 +1,11 @@ package insights import ( + "context" "fmt" + "slices" + "strings" + "time" "github.com/spf13/cobra" @@ -10,6 +14,8 @@ import ( "github.com/planetscale/cli/internal/printer" ) +var anomalyPeriods = []string{"15m", "1h", "3h", "6h", "12h", "1d", "2d", "7d", "8d"} + // AnomalyRow is an anomaly formatted for table output. type AnomalyRow struct { ID string `header:"id" json:"id"` @@ -19,16 +25,69 @@ type AnomalyRow struct { MinutesInViolation int64 `header:"minutes in violation" json:"minutes_in_violation"` } +// AnomalyDetailRow is an anomaly formatted for human-readable detail output. +type AnomalyDetailRow struct { + ID string `header:"id"` + Active bool `header:"active"` + PeriodStart string `header:"started"` + PeriodEnd string `header:"ended"` + MinutesInViolation int64 `header:"minutes in violation"` + DurationSeconds float64 `header:"duration (seconds)"` +} + +// AnomalyCorrelationRow is a correlated query formatted for human-readable output. +type AnomalyCorrelationRow struct { + Correlation float64 `header:"correlation"` + Tablet string `header:"tablet"` + Keyspace string `header:"keyspace"` + Fingerprint string `header:"fingerprint"` + Query string `header:"query"` +} + // AnomaliesCmd lists detected resource anomalies for a branch. func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + from string + to string + period string + } + cmd := &cobra.Command{ - Use: "anomalies ", - Short: "List detected resource anomalies (CPU, memory, IOPS, rows read/written)", - Args: cmdutil.RequiredArgs("database", "branch"), + Use: "anomalies [anomaly-id]", + Short: "List detected anomalies or retrieve one anomaly with its correlated queries", + Example: ` # Anomalies detected during the last 24 hours + pscale insights anomalies mydb main --org myorg --period 1d + + # Anomalies that started in a date range (date-only --to includes the whole day) + pscale insights anomalies mydb main --org myorg \ + --from 07/23 --to 07/25 + + # Anomalies that started in an explicit timestamp range + pscale insights anomalies mydb main --org myorg \ + --from 2026-07-24T00:00:00Z --to 2026-07-25T00:00:00Z + + # Retrieve one anomaly and its correlated query patterns + pscale insights anomalies mydb main 4888442 --org myorg`, + Args: cobra.MatchAll(cmdutil.RequiredArgs("database", "branch"), cobra.MaximumNArgs(3)), RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() database, branch := args[0], args[1] + if len(args) == 3 { + if flags.from != "" || flags.to != "" || flags.period != "" { + return fmt.Errorf("--from, --to, and --period cannot be used when retrieving an anomaly by ID") + } + return printAnomalyDetail(ctx, ch, database, branch, args[2]) + } + + from, to, err := anomalyTimeRange(flags.from, flags.to, time.Now()) + if err != nil { + return err + } + if flags.period != "" && !slices.Contains(anomalyPeriods, flags.period) { + return fmt.Errorf("invalid --period %q, must be one of: %s", flags.period, strings.Join(anomalyPeriods, ", ")) + } + client, err := ch.Client() if err != nil { return err @@ -42,7 +101,7 @@ func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command { Organization: ch.Config.Organization, Database: database, Branch: branch, - }) + }, ps.WithPeriod(flags.period), ps.WithTimeRange(from, to)) if err != nil { return notFoundError(ch, err, database, branch) } @@ -76,5 +135,121 @@ func AnomaliesCmd(ch *cmdutil.Helper) *cobra.Command { }, } + cmd.Flags().StringVar(&flags.from, "from", "", "Start of a time range (RFC3339, YYYY-MM-DD, MM/DD/YYYY, or MM/DD; date-only values use local time)") + cmd.Flags().StringVar(&flags.to, "to", "", "End of a time range (same formats as --from; a date includes the whole day; defaults to now)") + cmd.Flags().StringVar(&flags.period, "period", "", fmt.Sprintf("Named time period, one of: %s", strings.Join(anomalyPeriods, ", "))) + cmd.MarkFlagsMutuallyExclusive("period", "from") + cmd.MarkFlagsMutuallyExclusive("period", "to") + return cmd } + +func printAnomalyDetail(ctx context.Context, ch *cmdutil.Helper, database, branch, anomalyID string) error { + client, err := ch.Client() + if err != nil { + return err + } + + end := ch.Printer.PrintProgress(fmt.Sprintf("Fetching anomaly %s for %s in %s...", + printer.BoldBlue(anomalyID), printer.BoldBlue(branch), printer.BoldBlue(database))) + 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(&AnomalyDetailRow{ + ID: anomaly.ID, + Active: anomaly.Active, + PeriodStart: anomaly.PeriodStart.Format("2006-01-02 15:04"), + PeriodEnd: periodEnd, + MinutesInViolation: anomaly.MinutesInViolation, + DurationSeconds: anomaly.Duration, + }); err != nil { + return err + } + + if len(anomaly.Correlations) == 0 { + ch.Printer.Println("\nNo correlated query patterns were identified.") + return nil + } + + ch.Printer.Println("\nCorrelated query patterns:") + rows := make([]*AnomalyCorrelationRow, 0, len(anomaly.Correlations)) + for _, correlation := range anomaly.Correlations { + rows = append(rows, &AnomalyCorrelationRow{ + Correlation: round2(correlation.CorrelationCoefficient), + Tablet: correlation.TabletType, + Keyspace: correlation.Keyspace, + Fingerprint: correlation.Fingerprint, + Query: truncate(correlation.NormalizedSQL, 80), + }) + } + return ch.Printer.PrintResource(rows) +} + +func anomalyTimeRange(fromValue, toValue string, now time.Time) (time.Time, time.Time, error) { + if fromValue == "" { + if toValue != "" { + return time.Time{}, time.Time{}, fmt.Errorf("--to requires --from") + } + return time.Time{}, time.Time{}, nil + } + + from, _, err := parseAnomalyTime(fromValue, now) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --from %q: use RFC3339, YYYY-MM-DD, MM/DD/YYYY, or MM/DD", fromValue) + } + if toValue == "" { + return from, time.Time{}, nil + } + + to, dateOnly, err := parseAnomalyTime(toValue, now) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --to %q: use RFC3339, YYYY-MM-DD, MM/DD/YYYY, or MM/DD", toValue) + } + if dateOnly { + to = to.AddDate(0, 0, 1) + } + if !from.Before(to) { + return time.Time{}, time.Time{}, fmt.Errorf("--from must be before --to") + } + return from, to, nil +} + +func parseAnomalyTime(value string, now time.Time) (time.Time, bool, error) { + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed, false, nil + } + + formats := []struct { + layout string + value string + }{ + {layout: "2006-01-02", value: value}, + {layout: "1/2/2006", value: value}, + {layout: "1/2/2006", value: fmt.Sprintf("%s/%d", value, now.Year())}, + } + for _, format := range formats { + if parsed, err := time.ParseInLocation(format.layout, format.value, now.Location()); err == nil { + return parsed, true, nil + } + } + + return time.Time{}, false, fmt.Errorf("unsupported time format") +} diff --git a/internal/cmd/insights/queries_test.go b/internal/cmd/insights/queries_test.go index e458dd9a..17b69734 100644 --- a/internal/cmd/insights/queries_test.go +++ b/internal/cmd/insights/queries_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "net/url" "testing" "time" @@ -109,6 +110,14 @@ func TestInsights_AnomaliesCmd(t *testing.T) { svc := &mock.QueryInsightsService{ ListAnomaliesFn: func(ctx context.Context, req *ps.ListAnomaliesRequest, opts ...ps.ListOption) ([]*ps.Anomaly, error) { + values := url.Values{} + listOpts := &ps.ListOptions{URLValues: &values} + for _, opt := range opts { + c.Assert(opt(listOpts), qt.IsNil) + } + c.Assert(values.Get("from"), qt.Equals, "2026-07-24T07:00:00Z") + c.Assert(values.Get("to"), qt.Equals, "2026-07-25T07:00:00Z") + c.Assert(values.Get("period"), qt.Equals, "") return []*ps.Anomaly{{ ID: "a1", Active: true, @@ -122,7 +131,11 @@ func TestInsights_AnomaliesCmd(t *testing.T) { ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc}) cmd := AnomaliesCmd(ch) - cmd.SetArgs([]string{"mydb", "main"}) + cmd.SetArgs([]string{ + "mydb", "main", + "--from", "2026-07-24T00:00:00-07:00", + "--to", "2026-07-25T00:00:00-07:00", + }) err := cmd.Execute() c.Assert(err, qt.IsNil) @@ -134,6 +147,169 @@ func TestInsights_AnomaliesCmd(t *testing.T) { c.Assert(out[0]["active"], qt.Equals, true) } +func TestInsights_AnomaliesCmd_Period(t *testing.T) { + c := qt.New(t) + + svc := &mock.QueryInsightsService{ + ListAnomaliesFn: func(ctx context.Context, req *ps.ListAnomaliesRequest, opts ...ps.ListOption) ([]*ps.Anomaly, error) { + values := url.Values{} + listOpts := &ps.ListOptions{URLValues: &values} + for _, opt := range opts { + c.Assert(opt(listOpts), qt.IsNil) + } + c.Assert(values.Get("period"), qt.Equals, "1d") + c.Assert(values.Get("from"), qt.Equals, "") + c.Assert(values.Get("to"), qt.Equals, "") + return nil, nil + }, + } + + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc}) + + cmd := AnomaliesCmd(ch) + cmd.SetArgs([]string{"mydb", "main", "--period", "1d"}) + c.Assert(cmd.Execute(), qt.IsNil) + c.Assert(svc.ListAnomaliesFnInvoked, qt.IsTrue) +} + +func TestInsights_AnomaliesCmd_GetByID(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, "4888442") + return &ps.Anomaly{ + ID: "4888442", + PeriodStart: time.Date(2026, 7, 24, 19, 15, 0, 0, time.UTC), + PeriodEnd: time.Date(2026, 7, 25, 0, 2, 0, 0, time.UTC), + MinutesInViolation: 234, + Correlations: []*ps.AnomalyCorrelation{{ + CorrelationCoefficient: 0.98, + Keyspace: "game", + Fingerprint: "abc123", + NormalizedSQL: "select * from spaceship where size > ?", + TabletType: "primary", + }}, + }, nil + }, + } + + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{QueryInsights: svc}) + cmd := AnomaliesCmd(ch) + cmd.SetArgs([]string{"mydb", "main", "4888442"}) + + 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, "4888442") + correlations := out["correlations"].([]any) + c.Assert(correlations[0].(map[string]any)["fingerprint"], qt.Equals, "abc123") +} + +func TestInsights_AnomaliesCmd_InvalidTimeSelection(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "to without from", + args: []string{"mydb", "main", "--to", "2026-07-25T00:00:00Z"}, + want: "--to requires --from", + }, + { + name: "invalid from", + args: []string{"mydb", "main", "--from", "July 24"}, + want: "invalid --from", + }, + { + name: "invalid to", + args: []string{"mydb", "main", "--from", "2026-07-24T00:00:00Z", "--to", "July 25"}, + want: "invalid --to", + }, + { + name: "reversed range", + args: []string{"mydb", "main", "--from", "2026-07-25T00:00:00Z", "--to", "2026-07-24T00:00:00Z"}, + want: "--from must be before --to", + }, + { + name: "unknown period", + args: []string{"mydb", "main", "--period", "24h"}, + want: `invalid --period "24h"`, + }, + { + name: "period and range", + args: []string{"mydb", "main", "--period", "1d", "--from", "2026-07-24T00:00:00Z"}, + want: "if any flags in the group", + }, + { + name: "detail with range", + args: []string{"mydb", "main", "4888442", "--period", "1d"}, + want: "cannot be used when retrieving an anomaly by ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := qt.New(t) + var buf bytes.Buffer + ch := testHelper(&buf, printer.JSON, &ps.Client{}) + + cmd := AnomaliesCmd(ch) + cmd.SilenceUsage = true + cmd.SetArgs(tt.args) + err := cmd.Execute() + + c.Assert(err, qt.IsNotNil) + c.Assert(err.Error(), qt.Contains, tt.want) + }) + } +} + +func TestAnomalyTimeRange_DateOnly(t *testing.T) { + c := qt.New(t) + location := time.FixedZone("PDT", -7*60*60) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, location) + + from, to, err := anomalyTimeRange("07/23", "07/25", now) + + c.Assert(err, qt.IsNil) + c.Assert(from, qt.DeepEquals, time.Date(2026, 7, 23, 0, 0, 0, 0, location)) + c.Assert(to, qt.DeepEquals, time.Date(2026, 7, 26, 0, 0, 0, 0, location)) +} + +func TestAnomalyTimeRange_HumanReadableFormats(t *testing.T) { + c := qt.New(t) + location := time.FixedZone("PDT", -7*60*60) + now := time.Date(2026, 7, 29, 12, 0, 0, 0, location) + tests := []struct { + value string + want time.Time + }{ + {value: "2026-07-23", want: time.Date(2026, 7, 23, 0, 0, 0, 0, location)}, + {value: "7/23/2026", want: time.Date(2026, 7, 23, 0, 0, 0, 0, location)}, + {value: "7/23", want: time.Date(2026, 7, 23, 0, 0, 0, 0, location)}, + } + + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + parsed, dateOnly, err := parseAnomalyTime(tt.value, now) + + c.Assert(err, qt.IsNil) + c.Assert(dateOnly, qt.IsTrue) + c.Assert(parsed, qt.DeepEquals, tt.want) + }) + } +} + func TestInsights_RecommendationsCmd(t *testing.T) { c := qt.New(t) diff --git a/internal/mock/insights.go b/internal/mock/insights.go index da668f3a..23c94cf6 100644 --- a/internal/mock/insights.go +++ b/internal/mock/insights.go @@ -15,6 +15,8 @@ type QueryInsightsService struct { ListAnomaliesFn func(context.Context, *ps.ListAnomaliesRequest, ...ps.ListOption) ([]*ps.Anomaly, error) ListAnomaliesFnInvoked bool + GetAnomalyFn func(context.Context, *ps.GetAnomalyRequest) (*ps.Anomaly, error) + GetAnomalyFnInvoked bool } func (s *QueryInsightsService) ListQueries(ctx context.Context, req *ps.ListQueryInsightsRequest, opts ...ps.ListOption) ([]*ps.QueryInsight, error) { @@ -32,6 +34,11 @@ func (s *QueryInsightsService) ListAnomalies(ctx context.Context, req *ps.ListAn return s.ListAnomaliesFn(ctx, req, opts...) } +func (s *QueryInsightsService) GetAnomaly(ctx context.Context, req *ps.GetAnomalyRequest) (*ps.Anomaly, error) { + s.GetAnomalyFnInvoked = true + return s.GetAnomalyFn(ctx, req) +} + type SchemaRecommendationService struct { ListFn func(context.Context, *ps.ListSchemaRecommendationsRequest, ...ps.ListOption) ([]*ps.SchemaRecommendation, error) ListFnInvoked bool diff --git a/internal/planetscale/insights.go b/internal/planetscale/insights.go index 1127443e..40babe18 100644 --- a/internal/planetscale/insights.go +++ b/internal/planetscale/insights.go @@ -16,6 +16,7 @@ type QueryInsightsService interface { ListQueries(context.Context, *ListQueryInsightsRequest, ...ListOption) ([]*QueryInsight, error) ListErrors(context.Context, *ListQueryInsightsErrorsRequest, ...ListOption) ([]*QueryInsightError, error) ListAnomalies(context.Context, *ListAnomaliesRequest, ...ListOption) ([]*Anomaly, error) + GetAnomaly(context.Context, *GetAnomalyRequest) (*Anomaly, error) } // QueryInsight is an aggregated statistics record for a normalized query @@ -68,14 +69,26 @@ type QueryInsightError struct { // Anomaly is a detected resource-usage anomaly on a branch's primary. 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 []*AnomalyCorrelation `json:"correlations,omitempty"` +} + +// AnomalyCorrelation identifies a query pattern correlated with an anomaly. +type AnomalyCorrelation struct { + ID string `json:"id"` + CorrelationCoefficient float64 `json:"r"` + Keyspace string `json:"keyspace"` + Fingerprint string `json:"fingerprint"` + NormalizedSQL string `json:"normalized_sql"` + SyntaxHighlightedSQL string `json:"syntax_highlighted_sql"` + TabletType string `json:"tablet_type"` } // ListQueryInsightsRequest is the request for listing query statistics for a branch. @@ -99,6 +112,14 @@ type ListAnomaliesRequest struct { Branch string } +// GetAnomalyRequest is the request for retrieving one anomaly and its correlated queries. +type GetAnomalyRequest struct { + Organization string + Database string + Branch string + AnomalyID string +} + // WithSort returns a ListOption that sets the "sort" and "dir" URL parameters. func WithSort(sort, dir string) ListOption { return func(opt *ListOptions) error { @@ -123,6 +144,21 @@ func WithPeriod(period string) ListOption { } } +// WithTimeRange returns a ListOption that sets the "from" and "to" URL +// parameters. A zero to time is omitted, allowing the API to use the current +// time as the end of the range. +func WithTimeRange(from, to time.Time) ListOption { + return func(opt *ListOptions) error { + if !from.IsZero() { + opt.URLValues.Set("from", from.UTC().Format(time.RFC3339Nano)) + } + if !to.IsZero() { + opt.URLValues.Set("to", to.UTC().Format(time.RFC3339Nano)) + } + return nil + } +} + type queryInsightsService struct { client *Client } @@ -187,6 +223,24 @@ func (s *queryInsightsService) ListAnomalies(ctx context.Context, request *ListA return resp.Anomalies, nil } +func (s *queryInsightsService) GetAnomaly(ctx context.Context, request *GetAnomalyRequest) (*Anomaly, error) { + req, err := s.client.newRequest( + http.MethodGet, + path.Join(insightsAPIPath(request.Organization, request.Database, request.Branch), "anomalies", request.AnomalyID), + 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 +} + func insightsAPIPath(org, db, branch string) string { return path.Join("v1/organizations", org, "databases", db, "branches", branch, "insights") } diff --git a/internal/planetscale/insights_test.go b/internal/planetscale/insights_test.go index 93e9e41a..3342ac86 100644 --- a/internal/planetscale/insights_test.go +++ b/internal/planetscale/insights_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" qt "github.com/frankban/quicktest" ) @@ -131,6 +132,9 @@ func TestQueryInsights_ListAnomalies(t *testing.T) { w.WriteHeader(200) 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") + c.Assert(r.URL.Query().Get("from"), qt.Equals, "2026-07-24T00:00:00Z") + c.Assert(r.URL.Query().Get("to"), qt.Equals, "2026-07-25T00:00:00Z") + c.Assert(r.URL.Query().Get("period"), qt.Equals, "") out := `{ "type": "list", @@ -157,7 +161,10 @@ func TestQueryInsights_ListAnomalies(t *testing.T) { Organization: testOrg, Database: testDatabase, Branch: "main", - }) + }, WithTimeRange( + time.Date(2026, 7, 24, 0, 0, 0, 0, time.UTC), + time.Date(2026, 7, 25, 0, 0, 0, 0, time.UTC), + )) c.Assert(err, qt.IsNil) c.Assert(len(anomalies), qt.Equals, 1) @@ -166,3 +173,47 @@ func TestQueryInsights_ListAnomalies(t *testing.T) { c.Assert(anomalies[0].Active, qt.Equals, false) c.Assert(anomalies[0].Duration, qt.Equals, 1800.0) } + +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/4888442") + + out := `{ + "id": "4888442", + "period_start": "2026-07-24T19:15:00Z", + "period_end": "2026-07-25T00:02:00Z", + "minutes_in_violation": 234, + "active": false, + "duration": 17220, + "correlations": [{ + "id": "correlation-1", + "r": 0.98, + "keyspace": "game", + "fingerprint": "abc123", + "normalized_sql": "select * from spaceship where size > ?", + "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: "4888442", + }) + + c.Assert(err, qt.IsNil) + c.Assert(anomaly.ID, qt.Equals, "4888442") + c.Assert(len(anomaly.Correlations), qt.Equals, 1) + c.Assert(anomaly.Correlations[0].Fingerprint, qt.Equals, "abc123") + c.Assert(anomaly.Correlations[0].CorrelationCoefficient, qt.Equals, 0.98) +}