Skip to content
Open
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Pending

### Added

- `scout anomalies` and `scout anomalies show <id>` — 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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
181 changes: 181 additions & 0 deletions cmd/anomalies.go
Original file line number Diff line number Diff line change
@@ -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 <id>",
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"
}
30 changes: 30 additions & 0 deletions cmd/anomalies_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
}
36 changes: 36 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
81 changes: 67 additions & 14 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"`
Expand All @@ -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"`
}

Expand Down
Loading