diff --git a/planetscale/insights.go b/planetscale/insights.go index 1127443..4b3d62e 100644 --- a/planetscale/insights.go +++ b/planetscale/insights.go @@ -10,12 +10,16 @@ import ( var _ QueryInsightsService = &queryInsightsService{} // QueryInsightsService is an interface for communicating with the PlanetScale -// Query Insights API: aggregated query statistics, query errors, and detected -// anomalies for a database branch. +// Query Insights API: aggregated query statistics, query errors, detected +// anomalies, per-fingerprint samples, and query tags for a database branch. type QueryInsightsService interface { ListQueries(context.Context, *ListQueryInsightsRequest, ...ListOption) ([]*QueryInsight, error) + ListQuerySamples(context.Context, *ListQuerySamplesRequest, ...ListOption) ([]*QuerySample, error) ListErrors(context.Context, *ListQueryInsightsErrorsRequest, ...ListOption) ([]*QueryInsightError, error) ListAnomalies(context.Context, *ListAnomaliesRequest, ...ListOption) ([]*Anomaly, error) + ListTags(context.Context, *ListQueryTagsRequest, ...ListOption) ([]*QueryTag, error) + GetTag(context.Context, *GetQueryTagRequest, ...ListOption) (*QueryTag, error) + ListTagSummaries(context.Context, *ListTagSummariesRequest, ...ListOption) ([]*TagSummary, error) } // QueryInsight is an aggregated statistics record for a normalized query @@ -99,6 +103,39 @@ type ListAnomaliesRequest struct { Branch string } +// QuerySampleTag is a name/value tag attached to an individual query execution. +type QuerySampleTag struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// QuerySample is an individual query execution recorded for a fingerprint. +type QuerySample struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + NormalizedSQL string `json:"normalized_sql"` + StatementType string `json:"statement_type"` + Keyspace string `json:"keyspace"` + Tables []string `json:"tables"` + Username string `json:"username"` + RemoteAddress string `json:"remote_address"` + RowsRead int64 `json:"rows_read"` + RowsAffected int64 `json:"rows_affected"` + RowsReturned int64 `json:"rows_returned"` + TotalDurationMillis float64 `json:"total_duration_millis"` + ErrorMessage string `json:"error_message"` + StartedAt time.Time `json:"started_at"` + Tags []QuerySampleTag `json:"tags"` +} + +// ListQuerySamplesRequest lists individual executions for a query fingerprint. +type ListQuerySamplesRequest struct { + Organization string + Database string + Branch string + Fingerprint string +} + // WithSort returns a ListOption that sets the "sort" and "dir" URL parameters. func WithSort(sort, dir string) ListOption { return func(opt *ListOptions) error { @@ -113,7 +150,7 @@ func WithSort(sort, dir string) ListOption { } // WithPeriod returns a ListOption that sets the "period" URL parameter -// (e.g. "1h", "24h"). +// (e.g. "1h", "1d"). func WithPeriod(period string) ListOption { return func(opt *ListOptions) error { if period != "" { @@ -187,6 +224,30 @@ func (s *queryInsightsService) ListAnomalies(ctx context.Context, request *ListA return resp.Anomalies, nil } +type querySamplesResponse struct { + Data []*QuerySample `json:"data"` +} + +func (s *queryInsightsService) ListQuerySamples(ctx context.Context, request *ListQuerySamplesRequest, opts ...ListOption) ([]*QuerySample, error) { + listOpts := defaultListOptions(opts...) + + req, err := s.client.newRequest(http.MethodGet, insightsFingerprintAPIPath(request.Organization, request.Database, request.Branch, request.Fingerprint), 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 insightsAPIPath(org, db, branch string) string { return path.Join("v1/organizations", org, "databases", db, "branches", branch, "insights") } + +func insightsFingerprintAPIPath(org, db, branch, fingerprint string) string { + return path.Join(insightsAPIPath(org, db, branch), fingerprint) +} diff --git a/planetscale/insights_tags.go b/planetscale/insights_tags.go new file mode 100644 index 0000000..925c67f --- /dev/null +++ b/planetscale/insights_tags.go @@ -0,0 +1,173 @@ +package planetscale + +import ( + "context" + "net/http" + "path" +) + +// QueryTag is a tag key observed on branch queries (sqlcommenter / system). +// ID is the dimension key used by the API (e.g. "Sapp", "Busername"). +// Name is the friendly key users see in the app (e.g. "app", "username"). +// Source is "sql" or "system". +type QueryTag struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + QueryCount int64 `json:"query_count"` + Values []QueryTagValue `json:"values"` +} + +// QueryTagValue is one observed value for a tag key. +type QueryTagValue struct { + Name string `json:"name"` + QueryCount int64 `json:"query_count"` + Kind string `json:"kind"` +} + +// TagSummary is query statistics grouped by one or more tag dimensions. +type TagSummary struct { + Dimensions map[string]string `json:"dimensions"` + QueryCount int64 `json:"query_count"` + ErrorCount int64 `json:"error_count"` + Tables []string `json:"tables"` + SumRowsRead int64 `json:"sum_rows_read"` + SumRowsReturned int64 `json:"sum_rows_returned"` + SumRowsAffected int64 `json:"sum_rows_affected"` + RowsReadPerReturned float64 `json:"rows_read_per_returned"` + SumTotalDurationMillis float64 `json:"sum_total_duration_millis"` + SumTotalDurationPct float64 `json:"sum_total_duration_percent"` + SumCPUDurationMillis float64 `json:"sum_cpu_duration_millis"` + SumIODurationMillis float64 `json:"sum_io_duration_millis"` + LastRunAt string `json:"last_run_at"` + TimePerQuery float64 `json:"time_per_query"` + P50Latency float64 `json:"p50_latency"` + P99Latency float64 `json:"p99_latency"` + MaxLatency float64 `json:"max_latency"` +} + +// ListQueryTagsRequest lists tag keys for a branch. +type ListQueryTagsRequest struct { + Organization string + Database string + Branch string +} + +// GetQueryTagRequest retrieves one tag key and its values. +// Tag is the dimension id (e.g. "Sapp"), not the display name. +type GetQueryTagRequest struct { + Organization string + Database string + Branch string + Tag string +} + +// ListTagSummariesRequest lists query stats grouped by tag dimensions. +// Tags are dimension ids from the tags list endpoint (e.g. "Sapp"). +type ListTagSummariesRequest struct { + Organization string + Database string + Branch string + Tags []string +} + +type queryTagsResponse struct { + Data []*QueryTag `json:"data"` +} + +type tagSummariesResponse struct { + Data []*TagSummary `json:"data"` +} + +// WithTags sets the tags[] query parameters (dimension ids for summaries). +func WithTags(tags []string) ListOption { + return func(opt *ListOptions) error { + for _, tag := range tags { + if tag != "" { + opt.URLValues.Add("tags[]", tag) + } + } + return nil + } +} + +// WithFingerprint sets the fingerprint query parameter. +func WithFingerprint(fingerprint string) ListOption { + return func(opt *ListOptions) error { + if fingerprint != "" { + opt.URLValues.Set("fingerprint", fingerprint) + } + return nil + } +} + +// WithKeyspace sets the keyspace query parameter. +func WithKeyspace(keyspace string) ListOption { + return func(opt *ListOptions) error { + if keyspace != "" { + opt.URLValues.Set("keyspace", keyspace) + } + return nil + } +} + +func (s *queryInsightsService) ListTags(ctx context.Context, request *ListQueryTagsRequest, opts ...ListOption) ([]*QueryTag, error) { + listOpts := defaultListOptions(opts...) + + req, err := s.client.newRequest(http.MethodGet, insightsTagsAPIPath(request.Organization, request.Database, request.Branch), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + resp := &queryTagsResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func (s *queryInsightsService) GetTag(ctx context.Context, request *GetQueryTagRequest, opts ...ListOption) (*QueryTag, error) { + listOpts := defaultListOptions(opts...) + + req, err := s.client.newRequest(http.MethodGet, insightsTagAPIPath(request.Organization, request.Database, request.Branch, request.Tag), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + tag := &QueryTag{} + if err := s.client.do(ctx, req, &tag); err != nil { + return nil, err + } + + return tag, nil +} + +func (s *queryInsightsService) ListTagSummaries(ctx context.Context, request *ListTagSummariesRequest, opts ...ListOption) ([]*TagSummary, error) { + opts = append([]ListOption{WithTags(request.Tags)}, opts...) + listOpts := defaultListOptions(opts...) + + req, err := s.client.newRequest(http.MethodGet, insightsTagSummariesAPIPath(request.Organization, request.Database, request.Branch), nil, WithQueryParams(*listOpts.URLValues)) + if err != nil { + return nil, err + } + + resp := &tagSummariesResponse{} + if err := s.client.do(ctx, req, &resp); err != nil { + return nil, err + } + + return resp.Data, nil +} + +func insightsTagsAPIPath(org, db, branch string) string { + return path.Join(insightsAPIPath(org, db, branch), "tags") +} + +func insightsTagAPIPath(org, db, branch, tag string) string { + return path.Join(insightsTagsAPIPath(org, db, branch), tag) +} + +func insightsTagSummariesAPIPath(org, db, branch string) string { + return path.Join(insightsTagsAPIPath(org, db, branch), "summaries") +} diff --git a/planetscale/insights_test.go b/planetscale/insights_test.go index 93e9e41..b9643e8 100644 --- a/planetscale/insights_test.go +++ b/planetscale/insights_test.go @@ -166,3 +166,156 @@ 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_ListQuerySamples(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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/b129e8fa") + c.Assert(r.URL.Query().Get("per_page"), qt.Equals, "10") + c.Assert(r.URL.Query().Get("period"), qt.Equals, "1h") + c.Assert(r.URL.Query().Get("keyspace"), qt.Equals, "public") + + out := `{ + "type": "list", + "data": [{ + "id": "exec-1", + "fingerprint": "b129e8fa", + "normalized_sql": "select * from users where id = ?", + "username": "app", + "rows_read": 1, + "rows_returned": 1, + "total_duration_millis": 2.5, + "started_at": "2026-08-11T18:00:00.000Z", + "error_message": "", + "tags": [{"name": "Sapp", "value": "web"}, {"name": "Busername", "value": "alice"}] + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + samples, err := client.QueryInsights.ListQuerySamples(context.Background(), &ListQuerySamplesRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + Fingerprint: "b129e8fa", + }, WithPerPage(10), WithPeriod("1h"), WithKeyspace("public")) + + c.Assert(err, qt.IsNil) + c.Assert(samples, qt.HasLen, 1) + c.Assert(samples[0].ID, qt.Equals, "exec-1") + c.Assert(samples[0].Username, qt.Equals, "app") + c.Assert(samples[0].TotalDurationMillis, qt.Equals, 2.5) + c.Assert(samples[0].Tags, qt.DeepEquals, []QuerySampleTag{ + {Name: "Sapp", Value: "web"}, + {Name: "Busername", Value: "alice"}, + }) +} + +func TestQueryInsights_ListTags(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/main/insights/tags") + + out := `{ + "type": "list", + "data": [{ + "id": "Sapp", + "name": "app", + "source": "sql", + "query_count": 100, + "values": [{"name": "web", "query_count": 80, "kind": "literal"}] + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + tags, err := client.QueryInsights.ListTags(context.Background(), &ListQueryTagsRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + }) + + c.Assert(err, qt.IsNil) + c.Assert(tags, qt.HasLen, 1) + c.Assert(tags[0].ID, qt.Equals, "Sapp") + c.Assert(tags[0].Name, qt.Equals, "app") + c.Assert(tags[0].Source, qt.Equals, "sql") +} + +func TestQueryInsights_GetTag(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/main/insights/tags/Sapp") + + out := `{"id":"Sapp","name":"app","source":"sql","query_count":100,"values":[{"name":"web","query_count":80,"kind":"literal"}]}` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + tag, err := client.QueryInsights.GetTag(context.Background(), &GetQueryTagRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + Tag: "Sapp", + }) + + c.Assert(err, qt.IsNil) + c.Assert(tag.ID, qt.Equals, "Sapp") + c.Assert(tag.Values, qt.HasLen, 1) +} + +func TestQueryInsights_ListTagSummaries(t *testing.T) { + c := qt.New(t) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + c.Assert(r.URL.Path, qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/branches/main/insights/tags/summaries") + c.Assert(r.URL.Query()["tags[]"], qt.DeepEquals, []string{"Sapp", "Busername"}) + c.Assert(r.URL.Query().Get("sort"), qt.Equals, "totalTime") + + out := `{ + "type": "list", + "data": [{ + "dimensions": {"Sapp": "web", "Busername": "alice"}, + "query_count": 10, + "sum_total_duration_millis": 50.5, + "p99_latency": 3.2 + }] + }` + _, err := w.Write([]byte(out)) + c.Assert(err, qt.IsNil) + })) + + client, err := NewClient(WithBaseURL(ts.URL)) + c.Assert(err, qt.IsNil) + + summaries, err := client.QueryInsights.ListTagSummaries(context.Background(), &ListTagSummariesRequest{ + Organization: testOrg, + Database: testDatabase, + Branch: "main", + Tags: []string{"Sapp", "Busername"}, + }, WithSort("totalTime", "desc")) + + c.Assert(err, qt.IsNil) + c.Assert(summaries, qt.HasLen, 1) + c.Assert(summaries[0].QueryCount, qt.Equals, int64(10)) + c.Assert(summaries[0].Dimensions["Sapp"], qt.Equals, "web") +} diff --git a/planetscale/schema_recommendations.go b/planetscale/schema_recommendations.go index a32f9b3..5da24f6 100644 --- a/planetscale/schema_recommendations.go +++ b/planetscale/schema_recommendations.go @@ -60,10 +60,12 @@ type GetSchemaRecommendationRequest struct { } // DismissSchemaRecommendationRequest is the request for dismissing a schema recommendation. +// ID is the recommendation sequence number from list/show (path param `number`). type DismissSchemaRecommendationRequest struct { Organization string `json:"-"` Database string `json:"-"` ID string `json:"-"` + Reason string `json:"reason,omitempty"` } type schemaRecommendationService struct { @@ -101,7 +103,7 @@ func (s *schemaRecommendationService) Get(ctx context.Context, request *GetSchem } func (s *schemaRecommendationService) Dismiss(ctx context.Context, request *DismissSchemaRecommendationRequest) (*SchemaRecommendation, error) { - req, err := s.client.newRequest(http.MethodPost, dismissSchemaRecommendationAPIPath(request.Organization, request.Database, request.ID), nil) + req, err := s.client.newRequest(http.MethodPost, dismissSchemaRecommendationAPIPath(request.Organization, request.Database, request.ID), request) if err != nil { return nil, err } diff --git a/planetscale/schema_recommendations_test.go b/planetscale/schema_recommendations_test.go index 6e4d2a4..3405f96 100644 --- a/planetscale/schema_recommendations_test.go +++ b/planetscale/schema_recommendations_test.go @@ -2,6 +2,7 @@ package planetscale import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -158,7 +159,11 @@ func TestSchemaRecommendations_Dismiss(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) c.Assert(r.Method, qt.Equals, http.MethodPost) - c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations/recommendation-123/dismiss") + c.Assert(r.URL.String(), qt.Equals, "/v1/organizations/my-org/databases/planetscale-go-test-db/schema-recommendations/1/dismiss") + + var body map[string]any + c.Assert(json.NewDecoder(r.Body).Decode(&body), qt.IsNil) + c.Assert(body["reason"], qt.Equals, "not applicable") out := `{ "id": "recommendation-123", @@ -191,7 +196,8 @@ func TestSchemaRecommendations_Dismiss(t *testing.T) { recommendation, err := client.SchemaRecommendations.Dismiss(ctx, &DismissSchemaRecommendationRequest{ Organization: testOrg, Database: testDatabase, - ID: "recommendation-123", + ID: "1", + Reason: "not applicable", }) c.Assert(err, qt.IsNil)