diff --git a/CHANGELOG.md b/CHANGELOG.md index 18cd63f..8b92613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Pending + +### Added + +- `scout anomalies` and `scout anomalies show ` — list and inspect anomaly events (#15) +- `scout anomalies show` — include `severity_threshold` and `duration_minutes` in the smart monitor detail block (#16) + ## [0.3.3] - 2026-04-02 ### Changed diff --git a/README.md b/README.md index 1a16c13..357c9d8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,16 @@ scout errors show 50560 --app 6 scout errors occurrences 50560 --app 6 ``` +### Anomalies + +```bash +scout anomalies --app 6 # List anomaly events (default: all states) +scout anomalies --app 6 --state open # Only open anomalies +scout anomalies --app 6 --metric response_time # Filter by metric +scout anomalies --app 6 --endpoint YXBpL21ldHJpY3M= # Filter by endpoint +scout anomalies show 1234 --app 6 # Detail with smart_monitor and deploy context +``` + ### Usage ```bash diff --git a/cmd/anomalies.go b/cmd/anomalies.go new file mode 100644 index 0000000..eb8a4c8 --- /dev/null +++ b/cmd/anomalies.go @@ -0,0 +1,181 @@ +package cmd + +import ( + "fmt" + "strconv" + + "github.com/scoutapm/scout/internal/output" + "github.com/spf13/cobra" +) + +var ( + anomaliesState string + anomaliesMetric string + anomaliesEndpoint string +) + +var anomaliesCmd = &cobra.Command{ + Use: "anomalies", + Short: "List and inspect anomaly events", + Run: runAnomaliesList, +} + +var anomaliesListCmd = &cobra.Command{ + Use: "list", + Short: "List anomaly events", + Run: runAnomaliesList, +} + +var anomaliesShowCmd = &cobra.Command{ + Use: "show ", + Short: "Show anomaly event details", + Args: cobra.ExactArgs(1), + Run: runAnomaliesShow, +} + +func init() { + for _, c := range []*cobra.Command{anomaliesCmd, anomaliesListCmd} { + c.Flags().StringVar(&anomaliesState, "state", "all", "Filter by state: open|closed|all") + c.Flags().StringVar(&anomaliesMetric, "metric", "", "Filter by metric (e.g. response_time)") + c.Flags().StringVar(&anomaliesEndpoint, "endpoint", "", "Filter by endpoint") + } + + anomaliesCmd.AddCommand(anomaliesListCmd, anomaliesShowCmd) + rootCmd.AddCommand(anomaliesCmd) +} + +func runAnomaliesList(cmd *cobra.Command, args []string) { + client, err := getClient() + if err != nil { + exitError(err.Error()) + } + + id, err := requireAppID() + if err != nil { + exitError(err.Error()) + } + + from, to, err := resolveTimeframe() + if err != nil { + exitError(err.Error()) + } + + events, err := client.ListAnomalyEvents(id, anomaliesState, anomaliesMetric, anomaliesEndpoint, from, to) + if err != nil { + handleAPIError(err) + return + } + + if structuredOutput(events) { + return + } + + total := len(events) + limit, _ := applyLimit(total) + + headers := []string{"ID", "State", "Metric", "Endpoint", "Started", "Z-score", "Multiplier"} + rows := make([][]string, limit) + for i := 0; i < limit; i++ { + e := events[i] + state := anomalyState(e.Open) + rows[i] = []string{ + strconv.Itoa(e.ID), + output.StatusColor(state).Render(state), + e.Metric, + e.Endpoint, + output.FormatRelativeTime(e.StartedAt), + fmt.Sprintf("%.1f", e.ZScore), + formatMultiplier(e.Multiplier), + } + } + + fmt.Println(output.RenderTable(headers, rows)) + printTruncated(limit, total) +} + +func runAnomaliesShow(cmd *cobra.Command, args []string) { + client, err := getClient() + if err != nil { + exitError(err.Error()) + } + + id, err := requireAppID() + if err != nil { + exitError(err.Error()) + } + + eventID, err := strconv.Atoi(args[0]) + if err != nil { + exitError("invalid anomaly event ID: " + args[0]) + } + + event, err := client.GetAnomalyEvent(id, eventID) + if err != nil { + handleAPIError(err) + return + } + + if structuredOutput(event) { + return + } + + state := anomalyState(event.Open) + fmt.Println(output.HeaderStyle.Render(fmt.Sprintf("Anomaly #%d", event.ID))) + fmt.Printf(" State: %s\n", output.StatusColor(state).Render(state)) + fmt.Printf(" Metric: %s\n", event.Metric) + if event.Endpoint != "" { + fmt.Printf(" Endpoint: %s\n", event.Endpoint) + } + fmt.Printf(" Direction: %s\n", event.Direction) + fmt.Printf(" Severity: %s\n", event.Severity) + fmt.Printf(" Started: %s\n", output.FormatRelativeTime(event.StartedAt)) + if event.EndedAt != nil { + fmt.Printf(" Ended: %s\n", output.FormatRelativeTime(*event.EndedAt)) + } + fmt.Printf(" Last seen: %s\n", output.FormatRelativeTime(event.LastSeenAt)) + fmt.Printf(" Z-score: %.2f\n", event.ZScore) + fmt.Printf(" Current: %.2f\n", event.CurrentValue) + fmt.Printf(" Baseline: %.2f\n", event.BaselineValue) + fmt.Printf(" Multiplier: %s\n", formatMultiplier(event.Multiplier)) + if event.BaselineStdDev != nil { + fmt.Printf(" Std dev: %.2f\n", *event.BaselineStdDev) + } + if event.DurationMinutes != nil { + fmt.Printf(" Duration: %d min\n", *event.DurationMinutes) + } + if event.Description != "" { + fmt.Printf(" Description: %s\n", event.Description) + } + + if event.SmartMonitor != nil { + fmt.Println() + fmt.Println(output.BoldStyle.Render("Smart Monitor")) + fmt.Printf(" ID: %d\n", event.SmartMonitor.ID) + fmt.Printf(" Name: %s\n", event.SmartMonitor.Name) + fmt.Printf(" Kind: %s\n", event.SmartMonitor.Kind) + fmt.Printf(" Severity threshold: %.1f\n", event.SmartMonitor.SeverityThreshold) + fmt.Printf(" Duration: %d min\n", event.SmartMonitor.DurationMinutes) + } + + if event.Deploy != nil { + fmt.Println() + fmt.Println(output.BoldStyle.Render("Deploy")) + fmt.Printf(" ID: %d\n", event.Deploy.ID) + fmt.Printf(" SHA: %s\n", event.Deploy.SHA) + fmt.Printf(" Deployed: %s\n", output.FormatRelativeTime(event.Deploy.DeployedAt)) + } +} + +func formatMultiplier(m *float64) string { + if m == nil { + return "—" + } + return fmt.Sprintf("%.1fx", *m) +} + +func anomalyState(open bool) string { + if open { + return "open" + } + return "closed" +} diff --git a/cmd/anomalies_test.go b/cmd/anomalies_test.go new file mode 100644 index 0000000..6598cb0 --- /dev/null +++ b/cmd/anomalies_test.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatMultiplier(t *testing.T) { + v15 := 1.5 + v32 := 3.2 + v10 := 10.0 + + tests := []struct { + name string + input *float64 + expected string + }{ + {"nil", nil, "—"}, + {"1.5x", &v15, "1.5x"}, + {"3.2x", &v32, "3.2x"}, + {"10.0x", &v10, "10.0x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, formatMultiplier(tt.input)) + }) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index 997c7cd..3c51e45 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -197,6 +197,42 @@ func (c *Client) ListErrorOccurrences(appID, errorID int) ([]ErrorOccurrence, er return r.Errors, nil } +func (c *Client) ListAnomalyEvents(appID int, state, metric, endpoint, from, to string) ([]AnomalyEvent, error) { + path := fmt.Sprintf("/api/v0/apps/%d/anomaly_events", appID) + params := map[string]string{"from": from, "to": to} + if state != "" { + params["state"] = state + } + if metric != "" { + params["metric"] = metric + } + if endpoint != "" { + params["endpoint"] = endpoint + } + results, err := c.get(path, params) + if err != nil { + return nil, err + } + var r AnomalyEventsResult + if err := json.Unmarshal(results, &r); err != nil { + return nil, err + } + return r.Events, nil +} + +func (c *Client) GetAnomalyEvent(appID, eventID int) (AnomalyEvent, error) { + path := fmt.Sprintf("/api/v0/apps/%d/anomaly_events/%d", appID, eventID) + results, err := c.get(path, nil) + if err != nil { + return AnomalyEvent{}, err + } + var r AnomalyEventResult + if err := json.Unmarshal(results, &r); err != nil { + return AnomalyEvent{}, err + } + return r.Event, nil +} + func (c *Client) ListInsights(appID int, from, to string) (*InsightsListResult, error) { path := fmt.Sprintf("/api/v0/apps/%d/insights", appID) results, err := c.get(path, map[string]string{"from": from, "to": to}) diff --git a/internal/api/types.go b/internal/api/types.go index 4845913..7660d03 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -64,8 +64,8 @@ func (mp MetricPoint) MarshalJSON() ([]byte, error) { // MetricsResult contains summaries and time series data. type MetricsResult struct { - Summaries map[string]float64 `json:"summaries"` - Series map[string][]MetricPoint `json:"series"` + Summaries map[string]float64 `json:"summaries"` + Series map[string][]MetricPoint `json:"series"` } // EndpointEntry represents a single endpoint's performance data. @@ -135,16 +135,16 @@ type TraceDetailResult struct { // ErrorGroup represents an error group. type ErrorGroup struct { - ID int `json:"id"` - Name string `json:"name"` - Message string `json:"message"` - Status string `json:"status"` - ErrorsCount int `json:"errors_count"` - LastErrorAt string `json:"last_error_at"` - RequestComponents json.RawMessage `json:"request_components"` - RequestURI string `json:"request_uri"` - AppEnvironment string `json:"app_environment"` - LatestError *ErrorOccurrence `json:"latest_error,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Message string `json:"message"` + Status string `json:"status"` + ErrorsCount int `json:"errors_count"` + LastErrorAt string `json:"last_error_at"` + RequestComponents json.RawMessage `json:"request_components"` + RequestURI string `json:"request_uri"` + AppEnvironment string `json:"app_environment"` + LatestError *ErrorOccurrence `json:"latest_error,omitempty"` } // ErrorGroupsResult wraps the error groups list response. @@ -176,6 +176,59 @@ type ErrorOccurrencesResult struct { Errors []ErrorOccurrence `json:"errors"` } +// AnomalyEvent represents an anomaly event detected by Scout. +// Detail-only fields are populated by GetAnomalyEvent. +type AnomalyEvent struct { + ID int `json:"id"` + Metric string `json:"metric"` + Endpoint string `json:"endpoint,omitempty"` + Direction string `json:"direction"` + Severity string `json:"severity"` + StartedAt string `json:"started_at"` + EndedAt *string `json:"ended_at,omitempty"` + LastSeenAt string `json:"last_seen_at"` + ZScore float64 `json:"z_score"` + CurrentValue float64 `json:"current_value"` + BaselineValue float64 `json:"baseline_value"` + Multiplier *float64 `json:"multiplier,omitempty"` + Open bool `json:"open"` + Description string `json:"description"` + SmartMonitorID *int `json:"smart_monitor_id,omitempty"` + DeployID *int `json:"deploy_id,omitempty"` + + // Detail-only fields + BaselineStdDev *float64 `json:"baseline_std_dev,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + SmartMonitor *AnomalySmartMonitor `json:"smart_monitor,omitempty"` + Deploy *AnomalyDeploy `json:"deploy,omitempty"` +} + +// AnomalySmartMonitor is the joined smart monitor on an anomaly event detail. +type AnomalySmartMonitor struct { + ID int `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + SeverityThreshold float64 `json:"severity_threshold"` + DurationMinutes int `json:"duration_minutes"` +} + +// AnomalyDeploy is the joined deploy on an anomaly event detail. +type AnomalyDeploy struct { + ID int `json:"id"` + SHA string `json:"sha"` + DeployedAt string `json:"deployed_at"` +} + +// AnomalyEventsResult wraps the anomaly events list response. +type AnomalyEventsResult struct { + Events []AnomalyEvent `json:"anomaly_events"` +} + +// AnomalyEventResult wraps a single anomaly event response. +type AnomalyEventResult struct { + Event AnomalyEvent `json:"anomaly_event"` +} + // InsightItem represents a single insight. type InsightItem struct { ID int `json:"id"` @@ -192,8 +245,8 @@ type InsightCategory struct { // InsightsTimeframe describes the time window for insights. type InsightsTimeframe struct { - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` DurationMinutes float64 `json:"duration_minutes"` }