Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,15 @@ jobs:
with:
go-version-file: go.mod

- name: gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "Not gofmt-formatted:"
echo "$unformatted"
exit 1
fi

- run: go vet ./...

- uses: golangci/golangci-lint-action@v6
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,27 @@

## Pending

### Changed

- `scout usage --app <id>` (without `--by-day`) now filters to that one app instead of silently reporting every app, matching `--by-day --app <id>` (#29)
- `scout usage --billing-period --json` now wraps the per-app result in an object carrying `billing_period` and `server_total`, so a script can tell which window it got and what the billed figure was. Without `--billing-period` the shape is unchanged (#29)
- `scout jobs metrics --type latency --json` renames the API's `total` sub-series to `execution_time_total`, which is what it actually holds; other metric types are unchanged (#29)
- `scout errors show` omits the `Count:` line when the API reports 0, which it does for every error group even when `errors list` shows a real count (#29)
- `scout insights show` prints nested fields as indented `key: value` pairs and empty values as `—`, instead of leaking Go's `map[...]` and `<nil>` formatting (#29)

### Fixed

- `scout setup <framework>` now links to a real docs page for every framework — the old `/docs/<framework>` URLs all 404'd because Scout's docs are organized by language (#27)
- `-n`/`--limit` is now applied to `--json` output, not just the human table, across every list command: `apps list`, `endpoints list`, `jobs list`, `traces list`, `errors list`, `errors occurrences`, `anomalies list`, `insights list`, `insights show`, and all four `usage` modes. Totals and percentages are still computed over the full result set (#29)
- `scout insights list` and `scout insights show` now honor `-n` at all; it previously had no effect on either (#29)
- `scout anomalies --endpoint` now accepts a Base64 endpoint id and a plain endpoint name as well as the scoped name from `anomalies list`, instead of silently matching nothing (#29)
- `--endpoint` and `--job` now reject a blank value client-side rather than passing it to the server (#29)
- A malformed `--job` value, or a job class name copy-pasted without its queue, now gets a clear client-side error instead of a raw `API error (500)` (#29)
- `scout auth logout` no longer fails on a machine with no config directory yet (#29)
- `--from`/`--to` now reject a reversed range and unreasonably large or negative relative durations, which previously produced silently empty or nonsensical results (#29)
- `--app 0` and negative app ids now report an invalid value instead of "no app specified" (#29)
- `scout traces show` on a 404 now explains that background job traces have no detail endpoint (#29)
- `scout insights show` field order is now stable; it previously ranged over a Go map and printed a different order every run (#29)

## [0.5.0] - 2026-09-10

Expand Down
36 changes: 22 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,15 @@ scout auth status
scout auth logout
```

Credentials are stored in `~/.config/scout-apm/config.json`. You can also set the `SCOUT_API_KEY` environment variable.
Credentials are stored in a per-user config file whose location follows the platform convention:

| Platform | Path |
| --- | --- |
| macOS | `~/Library/Application Support/scout-apm/config.json` |
| Linux | `$XDG_CONFIG_HOME/scout-apm/config.json`, defaulting to `~/.config/scout-apm/config.json` |
| Windows | `%AppData%\scout-apm\config.json` |

`scout auth status` prints the path in use. You can also set the `SCOUT_API_KEY` environment variable, which takes precedence over the config file.

## Usage

Expand Down Expand Up @@ -102,24 +110,28 @@ Job IDs are in the `scout jobs list --json` output (`job_id`), or pass the job's
```bash
scout traces list --endpoint YXBpL21ldHJpY3Mvc2hvdw== --app 6
scout traces list --job ZGVmYXVsdC9NeVdvcmtlcg== --app 6
scout traces show 12345 --app 6
scout traces show <trace-id> --app 6
```

Trace ids come from `scout traces list`.

### Errors

```bash
scout errors list --app 6
scout errors show 50560 --app 6
scout errors occurrences 50560 --app 6
scout errors show <error-group-id> --app 6
scout errors occurrences <error-group-id> --app 6
```

Error group ids come from `scout errors list`.

### 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 --app 6 --endpoint Controller/users/index # Filter by endpoint (a name from 'anomalies list', or a Base64 endpoint id)
scout anomalies show 1234 --app 6 # Detail with smart_monitor and deploy context
```

Expand Down Expand Up @@ -168,8 +180,7 @@ scout setup rails # Show setup docs for a framework

| Flag | Description |
|------|-------------|
| `--json` | Output raw JSON |
| `--toon` | Output in [TOON](https://toonformat.dev/) format (auto-enabled when piped) |
| `--json` | Output raw JSON (auto-enabled when piped) |
| `--app <id>` | Application ID (or set `default_app_id` in config) |
| `--from <time>` | Start time — relative (`1h`, `7d`, `30m`, `2w`) or ISO 8601 |
| `--to <time>` | End time (default: now) |
Expand All @@ -178,22 +189,19 @@ scout setup rails # Show setup docs for a framework

## LLM / Agent Usage

When output is piped, Scout CLI automatically switches to [TOON](https://toonformat.dev/) format — a token-efficient structured format designed for LLM consumption. This means tools like Claude Code, scripts, and other agents get compact, parseable output by default.
When output is piped, Scout CLI automatically switches to JSON. Tools like Claude Code, scripts, and other agents get structured, parseable output by default — no flags needed. Human-readable tables and charts are used only when writing to a terminal.

```bash
# TOON output is automatic when piped
# JSON output is automatic when piped
scout apps list | llm "which app has the most endpoints?"

# Force TOON in a terminal
scout metrics get --type response_time --app 6 --toon

# Use --json if you need raw JSON instead
# Force JSON in a terminal
scout metrics get --type response_time --app 6 --json
```

## Configuration

Config file: `~/.config/scout-apm/config.json`
Config file location (see [Authentication](#authentication) for the full table): `~/Library/Application Support/scout-apm/config.json` on macOS, `~/.config/scout-apm/config.json` on Linux. Run `scout auth status` to print the path in use.

```json
{
Expand Down
62 changes: 55 additions & 7 deletions cmd/anomalies.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package cmd
import (
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf8"

"github.com/scoutapm/scout/internal/output"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -55,27 +58,34 @@ func runAnomaliesList(cmd *cobra.Command, args []string) {
exitError(err.Error())
}

endpoint := ""
if cmd.Flags().Changed("endpoint") {
endpoint, err = resolveAnomalyEndpoint(anomaliesEndpoint)
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)
events, err := client.ListAnomalyEvents(id, anomaliesState, anomaliesMetric, endpoint, from, to)
if err != nil {
handleAPIError(err)
return
}

events, total := limitSlice(events)

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++ {
rows := make([][]string, len(events))
for i := range events {
e := events[i]
state := anomalyState(e.Open)
rows[i] = []string{
Expand All @@ -90,7 +100,45 @@ func runAnomaliesList(cmd *cobra.Command, args []string) {
}

fmt.Println(output.RenderTable(headers, rows))
printTruncated(limit, total)
printTruncated(len(events), total)
}

// resolveAnomalyEndpoint accepts the endpoint forms used elsewhere in the CLI
// and returns the scoped metric name the anomaly events API matches on.
//
// Anomalies are recorded against the full metric name ("Controller/users/index")
// shown in 'scout anomalies list', while every other --endpoint flag takes the
// Base64 URL-safe endpoint id from 'scout endpoints list'. Both are accepted
// here, as is the plain endpoint name, so a base64 id no longer silently
// matches nothing.
func resolveAnomalyEndpoint(s string) (string, error) {
name, err := requireFlagValue("endpoint", s)
if err != nil {
return "", err
}
if !strings.Contains(name, "/") {
decoded, ok := decodeBase64ID(name)
if !ok {
return "", fmt.Errorf("invalid --endpoint %q — expected an endpoint name from 'scout anomalies list' (e.g. Controller/users/index) or a Base64 endpoint id from 'scout endpoints list --json'", s)
}
name = decoded
}
if hasMetricScope(name) {
return name, nil
}
return "Controller/" + name, nil
}

// hasMetricScope reports whether a name already carries a metric scope prefix
// such as "Controller/" or "Job/". Endpoint names on their own are the
// lowercase path portion ("users/index").
func hasMetricScope(name string) bool {
scope, rest, ok := strings.Cut(name, "/")
if !ok || scope == "" || rest == "" {
return false
}
r, _ := utf8.DecodeRuneInString(scope)
return unicode.IsUpper(r)
}

func runAnomaliesShow(cmd *cobra.Command, args []string) {
Expand Down
44 changes: 44 additions & 0 deletions cmd/anomalies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,52 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestResolveAnomalyEndpoint(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"scoped name passes through", "Controller/users/index", "Controller/users/index"},
{"job scope passes through", "Job/default/MyWorker", "Job/default/MyWorker"},
{"plain name gains the Controller scope", "users/index", "Controller/users/index"},
{"base64 endpoint id is decoded and scoped", "dXNlcnMvaW5kZXg=", "Controller/users/index"},
{"unpadded base64 id", "dXNlcnMvaW5kZXg", "Controller/users/index"},
{"surrounding whitespace is trimmed", " Controller/users/index ", "Controller/users/index"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveAnomalyEndpoint(tt.input)
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}

func TestResolveAnomalyEndpointRejectsBadValues(t *testing.T) {
_, err := resolveAnomalyEndpoint("")
require.Error(t, err)
assert.Equal(t, "--endpoint requires a value", err.Error())

// No slash and not decodable: nothing sensible to send to the API.
_, err = resolveAnomalyEndpoint("not base64!!")
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid --endpoint")
}

func TestHasMetricScope(t *testing.T) {
assert.True(t, hasMetricScope("Controller/users/index"))
assert.True(t, hasMetricScope("Job/default/MyWorker"))
assert.False(t, hasMetricScope("users/index"))
assert.False(t, hasMetricScope("users"))
assert.False(t, hasMetricScope("/leading-slash"))
assert.False(t, hasMetricScope("Controller/"))
}

func TestFormatMultiplier(t *testing.T) {
v15 := 1.5
v32 := 3.2
Expand Down
24 changes: 18 additions & 6 deletions cmd/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,15 @@ func runAppsList(cmd *cobra.Command, args []string) {
return
}

apps, total := limitSlice(apps)

if structuredOutput(apps) {
return
}

total := len(apps)
limit, _ := applyLimit(total)

headers := []string{"ID", "Name", "Last Reported"}
rows := make([][]string, limit)
for i := 0; i < limit; i++ {
rows := make([][]string, len(apps))
for i := range apps {
app := apps[i]
lastReported := ""
if app.LastReportedAt != "" {
Expand All @@ -66,7 +65,7 @@ func runAppsList(cmd *cobra.Command, args []string) {
}

fmt.Println(output.RenderTable(headers, rows))
printTruncated(limit, total)
printTruncated(len(apps), total)
}

func runAppsShow(cmd *cobra.Command, args []string) {
Expand All @@ -86,6 +85,19 @@ func runAppsShow(cmd *cobra.Command, args []string) {
return
}

// The single-app payload omits last_reported_at, which the list payload
// carries; fill it in so 'apps show' isn't thinner than 'apps list'.
if app.LastReportedAt == "" {
if apps, listErr := client.ListApps(); listErr == nil {
for _, a := range apps {
if a.ID == app.ID {
app.LastReportedAt = a.LastReportedAt
break
}
}
}
}

if structuredOutput(app) {
return
}
Expand Down
3 changes: 3 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func runAuthStatus(cmd *cobra.Command, args []string) {
"authenticated": key != "",
"api_key_prefix": maskKey(key),
"api_url": url,
"config_path": config.Path(),
}
if cfg.DefaultAppID > 0 {
data["default_app_id"] = cfg.DefaultAppID
Expand All @@ -209,13 +210,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) {

if key == "" {
fmt.Println(output.WarningStyle.Render("Not authenticated"))
fmt.Printf(" Config: %s\n", config.Path())
fmt.Println(output.DimStyle.Render("Run 'scout auth login' to authenticate"))
return
}

fmt.Println(output.SuccessStyle.Render("✓ Authenticated"))
fmt.Printf(" API Key: %s\n", maskKey(key))
fmt.Printf(" API URL: %s\n", url)
fmt.Printf(" Config: %s\n", config.Path())
if cfg.DefaultAppID > 0 {
fmt.Printf(" Default App: %d\n", cfg.DefaultAppID)
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ func renderUsageBar(used, limit int64) string {
if filled < 0 {
filled = 0
}
// Anything under 2.5% rounds to no blocks at all, which reads as zero
// usage. Show one block instead so "a little" is distinguishable.
if filled == 0 && used > 0 {
filled = 1
}

bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)

Expand Down
7 changes: 7 additions & 0 deletions cmd/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func TestRenderUsageBarFillWidth(t *testing.T) {
assert.Contains(t, renderUsageBar(300, 100), "████████████████████")
}

func TestRenderUsageBarFloorsNonzeroUsage(t *testing.T) {
// 0.1% rounds to no filled blocks, which reads as no usage at all.
assert.Contains(t, renderUsageBar(1, 1000), "█░░░░░░░░░░░░░░░░░░░")
// True zero stays empty.
assert.Contains(t, renderUsageBar(0, 1000), "░░░░░░░░░░░░░░░░░░░░")
}

func TestFormatBillingDate(t *testing.T) {
tests := []struct {
input string
Expand Down
Loading
Loading