From 99cd55a72ca97705c8f7d01bf74b226dd05045ef Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 16:24:09 +0100 Subject: [PATCH 01/11] fix(stats): return write errors from stats historical Previously, write errors in the JSON and table output paths were logged but silently swallowed. Now they are properly returned to the caller. Also removes an unused service ID parameter from writeBlocksJSON. --- CHANGELOG.md | 1 + pkg/commands/stats/historical.go | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c7e27a2..09896187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Breaking: ### Bug Fixes: +- fix(stats): `stats historical` now returns write errors instead of silently swallowing them. ### Enhancements: diff --git a/pkg/commands/stats/historical.go b/pkg/commands/stats/historical.go index f85ff5436..e17cfb637 100644 --- a/pkg/commands/stats/historical.go +++ b/pkg/commands/stats/historical.go @@ -96,20 +96,20 @@ func (c *HistoricalCommand) Exec(_ io.Reader, out io.Writer) error { switch c.formatFlag { case "json": - err := writeBlocksJSON(out, serviceID, envelope.Data) - if err != nil { + if err := writeBlocksJSON(out, envelope.Data); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) + return err } default: writeHeader(out, envelope.Meta) - err := writeBlocks(out, serviceID, envelope.Data) - if err != nil { + if err := writeBlocks(out, serviceID, envelope.Data); err != nil { c.Globals.ErrLog.AddWithContext(err, map[string]any{ "Service ID": serviceID, }) + return err } } @@ -134,7 +134,7 @@ func writeBlocks(out io.Writer, service string, blocks []statsResponseData) erro return nil } -func writeBlocksJSON(out io.Writer, _ string, blocks []statsResponseData) error { +func writeBlocksJSON(out io.Writer, blocks []statsResponseData) error { for _, block := range blocks { if err := json.NewEncoder(out).Encode(block); err != nil { return err From afa3b4e4d302590998651a7bdca97b159de2e144 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 16:24:22 +0100 Subject: [PATCH 02/11] feat(stats): add --field flag to stats historical Allows filtering historical stats output to a single field (e.g. bandwidth, requests). --- CHANGELOG.md | 1 + pkg/commands/stats/historical.go | 5 +++++ pkg/commands/stats/historical_test.go | 23 ++++++++++++++++++++++- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09896187a..a5ffcb764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - fix(stats): `stats historical` now returns write errors instead of silently swallowing them. ### Enhancements: +- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/commands/stats/historical.go b/pkg/commands/stats/historical.go index e17cfb637..cca934978 100644 --- a/pkg/commands/stats/historical.go +++ b/pkg/commands/stats/historical.go @@ -19,6 +19,7 @@ type HistoricalCommand struct { argparser.Base by string + field string formatFlag string from string region string @@ -45,6 +46,7 @@ func NewHistoricalCommand(parent argparser.Registerer, g *global.Data) *Historic Dst: &c.serviceName.Value, }) + c.CmdClause.Flag("field", "Filter to a single stats field (e.g. bandwidth, requests)").StringVar(&c.field) c.CmdClause.Flag("from", "From time, accepted formats at https://fastly.dev/reference/api/metrics-stats/historical-stats").StringVar(&c.from) c.CmdClause.Flag("to", "To time").StringVar(&c.to) c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") @@ -71,6 +73,9 @@ func (c *HistoricalCommand) Exec(_ io.Reader, out io.Writer) error { if c.by != "" { input.By = &c.by } + if c.field != "" { + input.Field = &c.field + } if c.from != "" { input.From = &c.from } diff --git a/pkg/commands/stats/historical_test.go b/pkg/commands/stats/historical_test.go index 6119d52d6..a0fb1f098 100644 --- a/pkg/commands/stats/historical_test.go +++ b/pkg/commands/stats/historical_test.go @@ -32,6 +32,16 @@ func TestHistorical(t *testing.T) { API: mock.API{GetStatsJSONFn: getStatsJSONOK}, WantOutput: historicalJSONOK, }, + { + args: args("stats historical --service-id=123 --field=bandwidth"), + api: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + wantOutput: historicalOK, + }, + { + args: args("stats historical --service-id=123 --field=bandwidth --format=json"), + api: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + wantOutput: historicalJSONOK, + }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "historical"}, scenarios) @@ -69,7 +79,7 @@ Requests: 0 var historicalJSONOK = `{"start_time":0} ` -func getStatsJSONOK(_ context.Context, _ *fastly.GetStatsInput, o any) error { +func unmarshalStatsJSON(o any) error { msg := []byte(` { "status": "success", @@ -86,6 +96,17 @@ func getStatsJSONOK(_ context.Context, _ *fastly.GetStatsInput, o any) error { return json.Unmarshal(msg, o) } +func getStatsJSONOK(_ context.Context, _ *fastly.GetStatsInput, o any) error { + return unmarshalStatsJSON(o) +} + +func getStatsJSONFieldOK(_ context.Context, i *fastly.GetStatsInput, o any) error { + if i.Field == nil || *i.Field != "bandwidth" { + return errTest + } + return unmarshalStatsJSON(o) +} + func getStatsJSONError(_ context.Context, _ *fastly.GetStatsInput, _ any) error { return errTest } From 70d74dacf911ccf896db17af1650a7ab6e307115 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 16:25:13 +0100 Subject: [PATCH 03/11] feat(stats): add stats aggregate subcommand Adds `stats aggregate` for viewing cross-service aggregated stats. Supports --from, --to, --by, --region, and --format flags. --- CHANGELOG.md | 1 + pkg/api/interface.go | 1 + pkg/commands/commands.go | 2 + pkg/commands/stats/aggregate.go | 87 ++++++++++++++++++++++++++++ pkg/commands/stats/aggregate_test.go | 84 +++++++++++++++++++++++++++ pkg/mock/api.go | 7 +++ 6 files changed, 182 insertions(+) create mode 100644 pkg/commands/stats/aggregate.go create mode 100644 pkg/commands/stats/aggregate_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a5ffcb764..fe5a7fbb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Enhancements: - feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. +- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index f7b41ff5e..824724ab3 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -240,6 +240,7 @@ type Interface interface { GetRegions(context.Context) (*fastly.RegionsResponse, error) GetStatsJSON(context.Context, *fastly.GetStatsInput, any) error + GetAggregateJSON(context.Context, *fastly.GetAggregateInput, any) error CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index d757bb2f6..c191c2065 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -704,6 +704,7 @@ func Define( // nolint:revive // function-length serviceresourcelinkList := serviceresourcelink.NewListCommand(serviceresourcelinkCmdRoot.CmdClause, data) serviceresourcelinkUpdate := serviceresourcelink.NewUpdateCommand(serviceresourcelinkCmdRoot.CmdClause, data) statsCmdRoot := stats.NewRootCommand(app, data) + statsAggregate := stats.NewAggregateCommand(statsCmdRoot.CmdClause, data) statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) @@ -1536,6 +1537,7 @@ func Define( // nolint:revive // function-length serviceVersionUpdate, ssoCmdRoot, statsCmdRoot, + statsAggregate, statsHistorical, statsRealtime, statsRegions, diff --git a/pkg/commands/stats/aggregate.go b/pkg/commands/stats/aggregate.go new file mode 100644 index 000000000..5eacaab44 --- /dev/null +++ b/pkg/commands/stats/aggregate.go @@ -0,0 +1,87 @@ +package stats + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" +) + +// AggregateCommand exposes the Aggregate Stats API. +type AggregateCommand struct { + argparser.Base + + by string + formatFlag string + from string + region string + to string +} + +// NewAggregateCommand is the "stats aggregate" subcommand. +func NewAggregateCommand(parent argparser.Registerer, g *global.Data) *AggregateCommand { + var c AggregateCommand + c.Globals = g + + c.CmdClause = parent.Command("aggregate", "View aggregated stats across all services") + c.CmdClause.Flag("from", "Start time").StringVar(&c.from) + c.CmdClause.Flag("to", "End time").StringVar(&c.to) + c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") + c.CmdClause.Flag("region", "Filter by region ('stats regions' to list)").StringVar(&c.region) + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *AggregateCommand) Exec(_ io.Reader, out io.Writer) error { + input := fastly.GetAggregateInput{} + if c.by != "" { + input.By = &c.by + } + if c.from != "" { + input.From = &c.from + } + if c.region != "" { + input.Region = &c.region + } + if c.to != "" { + input.To = &c.to + } + + var envelope statsResponse + err := c.Globals.APIClient.GetAggregateJSON(context.TODO(), &input, &envelope) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if envelope.Status != statusSuccess { + return fmt.Errorf("non-success response: %s", envelope.Msg) + } + + switch c.formatFlag { + case "json": + for _, block := range envelope.Data { + if err := json.NewEncoder(out).Encode(block); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + } + default: + writeHeader(out, envelope.Meta) + for _, block := range envelope.Data { + if err := fmtBlock(out, "aggregate", block); err != nil { + c.Globals.ErrLog.Add(err) + return err + } + } + } + + return nil +} diff --git a/pkg/commands/stats/aggregate_test.go b/pkg/commands/stats/aggregate_test.go new file mode 100644 index 000000000..be538a9cb --- /dev/null +++ b/pkg/commands/stats/aggregate_test.go @@ -0,0 +1,84 @@ +package stats_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "testing" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestAggregate(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + name string + args []string + api mock.API + wantError string + wantOutput string + }{ + { + name: "success table", + args: args("stats aggregate"), + api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, + wantOutput: "From:", + }, + { + name: "success json", + args: args("stats aggregate --format=json"), + api: mock.API{GetAggregateJSONFn: getAggregateJSONOK}, + wantOutput: `"start_time":0`, + }, + { + name: "non-success status", + args: args("stats aggregate"), + api: mock.API{GetAggregateJSONFn: getAggregateJSONNonSuccess}, + wantError: "non-success response", + }, + { + name: "api error", + args: args("stats aggregate"), + api: mock.API{GetAggregateJSONFn: getAggregateJSONError}, + wantError: errTest.Error(), + }, + } + for _, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(tc.args, &stdout) + opts.APIClientFactory = mock.APIClient(tc.api) + return opts, nil + } + err := app.Run(tc.args, nil) + testutil.AssertErrorContains(t, err, tc.wantError) + testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) + }) + } +} + +func getAggregateJSONOK(_ context.Context, _ *fastly.GetAggregateInput, o any) error { + msg := []byte(`{ + "status": "success", + "meta": {"to": "Thu May 16 20:08:35 UTC 2013", "from": "Wed May 15 20:08:35 UTC 2013", "by": "day", "region": "all"}, + "msg": null, + "data": [{"start_time": 0}] +}`) + return json.Unmarshal(msg, o) +} + +func getAggregateJSONNonSuccess(_ context.Context, _ *fastly.GetAggregateInput, o any) error { + msg := []byte(`{"status": "error", "msg": "bad request", "meta": {}, "data": []}`) + return json.Unmarshal(msg, o) +} + +func getAggregateJSONError(_ context.Context, _ *fastly.GetAggregateInput, _ any) error { + return errTest +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 82b7920b3..6daefd473 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -230,6 +230,8 @@ type API struct { GetRegionsFn func(context.Context) (*fastly.RegionsResponse, error) GetStatsJSONFn func(context.Context, *fastly.GetStatsInput, any) error + GetAggregateJSONFn func(context.Context, *fastly.GetAggregateInput, any) error + CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) @@ -1319,6 +1321,11 @@ func (m API) GetStatsJSON(ctx context.Context, i *fastly.GetStatsInput, dst any) return m.GetStatsJSONFn(ctx, i, dst) } +// GetAggregateJSON implements Interface. +func (m API) GetAggregateJSON(ctx context.Context, i *fastly.GetAggregateInput, dst any) error { + return m.GetAggregateJSONFn(ctx, i, dst) +} + // CreateManagedLogging implements Interface. func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(ctx, i) From 27cb8a0799784d4ca4f38d7ace551969b779565c Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 16:25:59 +0100 Subject: [PATCH 04/11] feat(stats): add stats usage subcommand Adds `stats usage` for viewing bandwidth/request usage stats. Supports --by-service flag to break down usage per service, and --from, --to, --by, --region, --format flags. --- CHANGELOG.md | 1 + pkg/api/interface.go | 2 + pkg/commands/commands.go | 2 + pkg/commands/stats/usage.go | 196 +++++++++++++++++++++++++++++++ pkg/commands/stats/usage_test.go | 150 +++++++++++++++++++++++ pkg/mock/api.go | 13 ++ 6 files changed, 364 insertions(+) create mode 100644 pkg/commands/stats/usage.go create mode 100644 pkg/commands/stats/usage_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5a7fbb5..f2dc7fa3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Enhancements: - feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. - feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. +- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 824724ab3..d7c3f871f 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -241,6 +241,8 @@ type Interface interface { GetRegions(context.Context) (*fastly.RegionsResponse, error) GetStatsJSON(context.Context, *fastly.GetStatsInput, any) error GetAggregateJSON(context.Context, *fastly.GetAggregateInput, any) error + GetUsage(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) + GetUsageByService(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index c191c2065..d25e705bd 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -708,6 +708,7 @@ func Define( // nolint:revive // function-length statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) + statsUsage := stats.NewUsageCommand(statsCmdRoot.CmdClause, data) tlsConfigCmdRoot := tlsconfig.NewRootCommand(app, data) tlsConfigDescribe := tlsconfig.NewDescribeCommand(tlsConfigCmdRoot.CmdClause, data) tlsConfigList := tlsconfig.NewListCommand(tlsConfigCmdRoot.CmdClause, data) @@ -1541,6 +1542,7 @@ func Define( // nolint:revive // function-length statsHistorical, statsRealtime, statsRegions, + statsUsage, tlsConfigCmdRoot, tlsConfigDescribe, tlsConfigList, diff --git a/pkg/commands/stats/usage.go b/pkg/commands/stats/usage.go new file mode 100644 index 000000000..44860710d --- /dev/null +++ b/pkg/commands/stats/usage.go @@ -0,0 +1,196 @@ +package stats + +import ( + "context" + "encoding/json" + "fmt" + "io" + "maps" + "slices" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// UsageCommand exposes the Usage Stats API. +type UsageCommand struct { + argparser.Base + + by string + byService bool + formatFlag string + from string + region string + to string +} + +// NewUsageCommand is the "stats usage" subcommand. +func NewUsageCommand(parent argparser.Registerer, g *global.Data) *UsageCommand { + var c UsageCommand + c.Globals = g + + c.CmdClause = parent.Command("usage", "View usage stats (bandwidth, requests)") + c.CmdClause.Flag("from", "Start time").StringVar(&c.from) + c.CmdClause.Flag("to", "End time").StringVar(&c.to) + c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") + c.CmdClause.Flag("region", "Filter by region ('stats regions' to list)").StringVar(&c.region) + c.CmdClause.Flag("by-service", "Break down usage by service").BoolVar(&c.byService) + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *UsageCommand) Exec(_ io.Reader, out io.Writer) error { + input := fastly.GetUsageInput{} + if c.by != "" { + input.By = &c.by + } + if c.from != "" { + input.From = &c.from + } + if c.region != "" { + input.Region = &c.region + } + if c.to != "" { + input.To = &c.to + } + + if c.byService { + return c.execByService(out, &input) + } + return c.execPlain(out, &input) +} + +func (c *UsageCommand) execPlain(out io.Writer, input *fastly.GetUsageInput) error { + resp, err := c.Globals.APIClient.GetUsage(context.TODO(), input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if fastly.ToValue(resp.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Message)) + } + + switch c.formatFlag { + case "json": + return writeUsageJSON(out, resp.Data) + default: + return writeUsageTable(out, resp.Data) + } +} + +func (c *UsageCommand) execByService(out io.Writer, input *fastly.GetUsageInput) error { + resp, err := c.Globals.APIClient.GetUsageByService(context.TODO(), input) + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + + if fastly.ToValue(resp.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Message)) + } + + switch c.formatFlag { + case "json": + return writeUsageByServiceJSON(out, resp.Data) + default: + return writeUsageByServiceTable(out, resp.Data) + } +} + +func writeUsageTable(out io.Writer, data *fastly.RegionsUsage) error { + if data == nil { + return nil + } + regions := slices.Sorted(maps.Keys(*data)) + for _, region := range regions { + usage := (*data)[region] + if usage == nil { + continue + } + text.Output(out, "Region: %s", region) + text.Output(out, " Bandwidth: %d", fastly.ToValue(usage.Bandwidth)) + text.Output(out, " Requests: %d", fastly.ToValue(usage.Requests)) + text.Output(out, " Compute Requests: %d", fastly.ToValue(usage.ComputeRequests)) + } + return nil +} + +func writeUsageJSON(out io.Writer, data *fastly.RegionsUsage) error { + if data == nil { + return json.NewEncoder(out).Encode(map[string]any{}) + } + return json.NewEncoder(out).Encode(usageToMap(*data)) +} + +func writeUsageByServiceTable(out io.Writer, data *fastly.ServicesByRegionsUsage) error { + if data == nil { + return nil + } + regions := slices.Sorted(maps.Keys(*data)) + for _, region := range regions { + services := (*data)[region] + if services == nil { + continue + } + text.Output(out, "Region: %s", region) + serviceIDs := slices.Sorted(maps.Keys(*services)) + for _, svcID := range serviceIDs { + usage := (*services)[svcID] + if usage == nil { + continue + } + text.Output(out, " Service: %s", svcID) + text.Output(out, " Bandwidth: %d", fastly.ToValue(usage.Bandwidth)) + text.Output(out, " Requests: %d", fastly.ToValue(usage.Requests)) + text.Output(out, " Compute Requests: %d", fastly.ToValue(usage.ComputeRequests)) + } + } + return nil +} + +func writeUsageByServiceJSON(out io.Writer, data *fastly.ServicesByRegionsUsage) error { + if data == nil { + return json.NewEncoder(out).Encode(map[string]any{}) + } + result := make(map[string]any) + for region, services := range *data { + if services == nil { + continue + } + regionMap := make(map[string]any) + for svcID, usage := range *services { + regionMap[svcID] = usageEntry(usage) + } + result[region] = regionMap + } + return json.NewEncoder(out).Encode(result) +} + +func usageToMap(data fastly.RegionsUsage) map[string]any { + result := make(map[string]any) + for region, usage := range data { + result[region] = usageEntry(usage) + } + return result +} + +func usageEntry(u *fastly.Usage) map[string]any { + if u == nil { + return map[string]any{ + "bandwidth": uint64(0), + "requests": uint64(0), + "compute_requests": uint64(0), + } + } + return map[string]any{ + "bandwidth": fastly.ToValue(u.Bandwidth), + "requests": fastly.ToValue(u.Requests), + "compute_requests": fastly.ToValue(u.ComputeRequests), + } +} diff --git a/pkg/commands/stats/usage_test.go b/pkg/commands/stats/usage_test.go new file mode 100644 index 000000000..8a8124903 --- /dev/null +++ b/pkg/commands/stats/usage_test.go @@ -0,0 +1,150 @@ +package stats_test + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestUsage(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + name string + args []string + api mock.API + wantError string + wantOutput string + }{ + { + name: "success plain", + args: args("stats usage"), + api: mock.API{GetUsageFn: getUsageOK}, + wantOutput: "Region: usa", + }, + { + name: "success json", + args: args("stats usage --format=json"), + api: mock.API{GetUsageFn: getUsageOK}, + wantOutput: "bandwidth", + }, + { + name: "success by-service", + args: args("stats usage --by-service"), + api: mock.API{GetUsageByServiceFn: getUsageByServiceOK}, + wantOutput: "Service: svc123", + }, + { + name: "success by-service json", + args: args("stats usage --by-service --format=json"), + api: mock.API{GetUsageByServiceFn: getUsageByServiceOK}, + wantOutput: "svc123", + }, + { + name: "nil usage entry json", + args: args("stats usage --format=json"), + api: mock.API{GetUsageFn: getUsageNilEntry}, + wantOutput: `"bandwidth"`, + }, + { + name: "nil usage entry table skipped", + args: args("stats usage"), + api: mock.API{GetUsageFn: getUsageWithNilEntry}, + wantOutput: "Region: europe", + }, + { + name: "non-success status", + args: args("stats usage"), + api: mock.API{GetUsageFn: getUsageNonSuccess}, + wantError: "non-success response", + }, + { + name: "api error", + args: args("stats usage"), + api: mock.API{GetUsageFn: getUsageError}, + wantError: errTest.Error(), + }, + } + for _, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(tc.args, &stdout) + opts.APIClientFactory = mock.APIClient(tc.api) + return opts, nil + } + err := app.Run(tc.args, nil) + testutil.AssertErrorContains(t, err, tc.wantError) + testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) + }) + } +} + +func getUsageOK(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return &fastly.UsageResponse{ + Status: fastly.ToPointer("success"), + Data: &fastly.RegionsUsage{ + "usa": &fastly.Usage{ + Bandwidth: fastly.ToPointer(uint64(1000)), + Requests: fastly.ToPointer(uint64(500)), + ComputeRequests: fastly.ToPointer(uint64(100)), + }, + }, + }, nil +} + +func getUsageByServiceOK(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) { + return &fastly.UsageByServiceResponse{ + Status: fastly.ToPointer("success"), + Data: &fastly.ServicesByRegionsUsage{ + "usa": &fastly.ServicesUsage{ + "svc123": &fastly.Usage{ + Bandwidth: fastly.ToPointer(uint64(1000)), + Requests: fastly.ToPointer(uint64(500)), + ComputeRequests: fastly.ToPointer(uint64(100)), + }, + }, + }, + }, nil +} + +func getUsageNilEntry(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return &fastly.UsageResponse{ + Status: fastly.ToPointer("success"), + Data: &fastly.RegionsUsage{ + "empty_region": nil, + }, + }, nil +} + +func getUsageWithNilEntry(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return &fastly.UsageResponse{ + Status: fastly.ToPointer("success"), + Data: &fastly.RegionsUsage{ + "empty_region": nil, + "europe": &fastly.Usage{ + Bandwidth: fastly.ToPointer(uint64(2000)), + Requests: fastly.ToPointer(uint64(300)), + ComputeRequests: fastly.ToPointer(uint64(50)), + }, + }, + }, nil +} + +func getUsageNonSuccess(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return &fastly.UsageResponse{ + Status: fastly.ToPointer("error"), + Message: fastly.ToPointer("bad request"), + }, nil +} + +func getUsageError(_ context.Context, _ *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return nil, errTest +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 6daefd473..8569f3ee8 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -232,6 +232,9 @@ type API struct { GetAggregateJSONFn func(context.Context, *fastly.GetAggregateInput, any) error + GetUsageFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) + GetUsageByServiceFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) + CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) @@ -1326,6 +1329,16 @@ func (m API) GetAggregateJSON(ctx context.Context, i *fastly.GetAggregateInput, return m.GetAggregateJSONFn(ctx, i, dst) } +// GetUsage implements Interface. +func (m API) GetUsage(ctx context.Context, i *fastly.GetUsageInput) (*fastly.UsageResponse, error) { + return m.GetUsageFn(ctx, i) +} + +// GetUsageByService implements Interface. +func (m API) GetUsageByService(ctx context.Context, i *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) { + return m.GetUsageByServiceFn(ctx, i) +} + // CreateManagedLogging implements Interface. func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(ctx, i) From 8ed97ad4bd6a288aca81e9da2e149d838adde2fe Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 17:29:16 +0100 Subject: [PATCH 05/11] feat(stats): add stats domain-inspector subcommand Adds `stats domain-inspector` for viewing domain-level metrics for a Fastly service. Supports service ID/name lookup, time range filtering (RFC3339/Unix), downsampling, domain/datacenter/region filtering, group-by dimensions, pagination, and JSON output. --- CHANGELOG.md | 1 + pkg/api/interface.go | 2 + pkg/commands/commands.go | 2 + pkg/commands/stats/domain_inspector.go | 188 +++++++++++++++++++ pkg/commands/stats/domain_inspector_test.go | 197 ++++++++++++++++++++ pkg/mock/api.go | 13 ++ 6 files changed, 403 insertions(+) create mode 100644 pkg/commands/stats/domain_inspector.go create mode 100644 pkg/commands/stats/domain_inspector_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dc7fa3e..6f496150c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. - feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. - feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. +- feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index d7c3f871f..cdb0f5824 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -243,6 +243,8 @@ type Interface interface { GetAggregateJSON(context.Context, *fastly.GetAggregateInput, any) error GetUsage(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) GetUsageByService(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) + GetDomainMetricsForService(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) + GetDomainMetricsForServiceJSON(context.Context, *fastly.GetDomainMetricsInput, any) error CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index d25e705bd..bd6ac3a4f 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -705,6 +705,7 @@ func Define( // nolint:revive // function-length serviceresourcelinkUpdate := serviceresourcelink.NewUpdateCommand(serviceresourcelinkCmdRoot.CmdClause, data) statsCmdRoot := stats.NewRootCommand(app, data) statsAggregate := stats.NewAggregateCommand(statsCmdRoot.CmdClause, data) + statsDomainInspector := stats.NewDomainInspectorCommand(statsCmdRoot.CmdClause, data) statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) @@ -1539,6 +1540,7 @@ func Define( // nolint:revive // function-length ssoCmdRoot, statsCmdRoot, statsAggregate, + statsDomainInspector, statsHistorical, statsRealtime, statsRegions, diff --git a/pkg/commands/stats/domain_inspector.go b/pkg/commands/stats/domain_inspector.go new file mode 100644 index 000000000..2f323e215 --- /dev/null +++ b/pkg/commands/stats/domain_inspector.go @@ -0,0 +1,188 @@ +package stats + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// DomainInspectorCommand exposes the Domain Inspector API. +type DomainInspectorCommand struct { + argparser.Base + + cursor string + datacenters []string + domains []string + downsample string + formatFlag string + from string + groupBy []string + limit int + metrics []string + regions []string + serviceName argparser.OptionalServiceNameID + to string +} + +// NewDomainInspectorCommand is the "stats domain-inspector" subcommand. +func NewDomainInspectorCommand(parent argparser.Registerer, g *global.Data) *DomainInspectorCommand { + var c DomainInspectorCommand + c.Globals = g + + c.CmdClause = parent.Command("domain-inspector", "View domain metrics for a Fastly service") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.CmdClause.Flag("from", "Start time (RFC3339 or Unix timestamp)").StringVar(&c.from) + c.CmdClause.Flag("to", "End time (RFC3339 or Unix timestamp)").StringVar(&c.to) + c.CmdClause.Flag("downsample", "Sample window (minute/hour/day)").EnumVar(&c.downsample, "minute", "hour", "day") + c.CmdClause.Flag("metric", "Metrics to retrieve (repeatable, up to 10)").StringsVar(&c.metrics) + c.CmdClause.Flag("domain", "Filter by domain (repeatable)").StringsVar(&c.domains) + c.CmdClause.Flag("datacenter", "Filter by POP (repeatable)").StringsVar(&c.datacenters) + c.CmdClause.Flag("region", "Filter by region (repeatable)").StringsVar(&c.regions) + c.CmdClause.Flag("group-by", "Dimensions to group by (repeatable)").StringsVar(&c.groupBy) + c.CmdClause.Flag("limit", "Max entries to return").IntVar(&c.limit) + c.CmdClause.Flag("cursor", "Pagination cursor from a previous response").StringVar(&c.cursor) + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *DomainInspectorCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := fastly.GetDomainMetricsInput{ + ServiceID: serviceID, + Datacenters: c.datacenters, + Domains: c.domains, + GroupBy: c.groupBy, + Metrics: c.metrics, + Regions: c.regions, + } + if c.cursor != "" { + input.Cursor = &c.cursor + } + if c.downsample != "" { + input.Downsample = &c.downsample + } + if c.from != "" { + t, err := parseTime(c.from) + if err != nil { + return fmt.Errorf("invalid --from value: %w", err) + } + input.Start = &t + } + if c.to != "" { + t, err := parseTime(c.to) + if err != nil { + return fmt.Errorf("invalid --to value: %w", err) + } + input.End = &t + } + if c.limit > 0 { + input.Limit = &c.limit + } + + switch c.formatFlag { + case "json": + var envelope struct { + Status *string `json:"status"` + } + var raw json.RawMessage + if err := c.Globals.APIClient.GetDomainMetricsForServiceJSON(context.TODO(), &input, &raw); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return err + } + if fastly.ToValue(envelope.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(envelope.Status)) + } + _, err := out.Write(raw) + if err != nil { + return err + } + fmt.Fprintln(out) + return nil + + default: + resp, err := c.Globals.APIClient.GetDomainMetricsForService(context.TODO(), &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + if fastly.ToValue(resp.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) + } + return writeDomainInspector(out, resp) + } +} + +func writeDomainInspector(out io.Writer, resp *fastly.DomainInspector) error { + if resp.Meta != nil { + if resp.Meta.Start != nil { + text.Output(out, "Start: %s", *resp.Meta.Start) + } + if resp.Meta.End != nil { + text.Output(out, "End: %s", *resp.Meta.End) + } + fmt.Fprintln(out, "---") + } + for _, d := range resp.Data { + if d.Dimensions != nil { + for k, v := range d.Dimensions { + text.Output(out, "%s: %s", k, fastly.ToValue(v)) + } + } + for _, v := range d.Values { + if v.Timestamp != nil { + text.Output(out, " Timestamp: %s", time.Unix(int64(*v.Timestamp), 0).UTC()) //nolint:gosec // timestamp won't overflow + } + text.Output(out, " Requests: %d", fastly.ToValue(v.Requests)) + text.Output(out, " Bandwidth: %d", fastly.ToValue(v.Bandwidth)) + text.Output(out, " Edge Requests: %d", fastly.ToValue(v.EdgeRequests)) + text.Output(out, " Edge Hit Ratio: %.4f", fastly.ToValue(v.EdgeHitRatio)) + } + } + if resp.Meta != nil && resp.Meta.NextCursor != nil { + text.Output(out, "Next cursor: %s", *resp.Meta.NextCursor) + } + return nil +} + +func parseTime(s string) (time.Time, error) { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + if epoch, err := strconv.ParseInt(s, 10, 64); err == nil { + return time.Unix(epoch, 0), nil + } + return time.Time{}, fmt.Errorf("cannot parse %q as RFC3339 or Unix timestamp", s) +} diff --git a/pkg/commands/stats/domain_inspector_test.go b/pkg/commands/stats/domain_inspector_test.go new file mode 100644 index 000000000..5a7e75cff --- /dev/null +++ b/pkg/commands/stats/domain_inspector_test.go @@ -0,0 +1,197 @@ +package stats_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "testing" + "time" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestDomainInspector(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + name string + args []string + api mock.API + wantError string + wantOutput string + }{ + { + name: "success table", + args: args("stats domain-inspector --service-id 123"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsOK, + }, + wantOutput: "Requests:", + }, + { + name: "success json", + args: args("stats domain-inspector --service-id 123 --format=json"), + api: mock.API{ + GetDomainMetricsForServiceJSONFn: getDomainMetricsJSONOK, + }, + wantOutput: "status", + }, + { + name: "non-success status", + args: args("stats domain-inspector --service-id 123"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsNonSuccess, + }, + wantError: "non-success response", + }, + { + name: "missing service ID", + args: args("stats domain-inspector"), + api: mock.API{}, + wantError: "error reading service", + }, + { + name: "non-success status json", + args: args("stats domain-inspector --service-id 123 --format=json"), + api: mock.API{ + GetDomainMetricsForServiceJSONFn: getDomainMetricsJSONNonSuccess, + }, + wantError: "non-success response", + }, + { + name: "api error", + args: args("stats domain-inspector --service-id 123"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsError, + }, + wantError: errTest.Error(), + }, + { + name: "from RFC3339 maps to Start", + args: args("stats domain-inspector --service-id 123 --from 2024-01-15T10:00:00Z"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), + }, + wantOutput: "Requests:", + }, + { + name: "from Unix epoch maps to Start", + args: args("stats domain-inspector --service-id 123 --from 1705312800"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsAssertStart(time.Unix(1705312800, 0)), + }, + wantOutput: "Requests:", + }, + { + name: "to RFC3339 maps to End", + args: args("stats domain-inspector --service-id 123 --to 2024-01-15T11:00:00Z"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), + }, + wantOutput: "Requests:", + }, + { + name: "from invalid format error", + args: args("stats domain-inspector --service-id 123 --from not-a-time"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsOK, + }, + wantError: "invalid --from value", + }, + { + name: "to invalid format error", + args: args("stats domain-inspector --service-id 123 --to not-a-time"), + api: mock.API{ + GetDomainMetricsForServiceFn: getDomainMetricsOK, + }, + wantError: "invalid --to value", + }, + } + for _, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(tc.args, &stdout) + opts.APIClientFactory = mock.APIClient(tc.api) + return opts, nil + } + err := app.Run(tc.args, nil) + testutil.AssertErrorContains(t, err, tc.wantError) + testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) + }) + } +} + +func domainMetricsOKResult() (*fastly.DomainInspector, error) { + return &fastly.DomainInspector{ + Status: fastly.ToPointer("success"), + Meta: &fastly.DomainMeta{ + Start: fastly.ToPointer("2024-01-15T10:00:00Z"), + End: fastly.ToPointer("2024-01-15T11:00:00Z"), + }, + Data: []*fastly.DomainData{ + { + Values: []*fastly.DomainMetrics{ + { + Requests: fastly.ToPointer(uint64(100)), + Bandwidth: fastly.ToPointer(uint64(5000)), + }, + }, + }, + }, + }, nil +} + +func getDomainMetricsOK(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return domainMetricsOKResult() +} + +func getDomainMetricsJSONOK(_ context.Context, _ *fastly.GetDomainMetricsInput, dst any) error { + msg := []byte(`{"status":"success","data":[]}`) + return json.Unmarshal(msg, dst) +} + +func getDomainMetricsJSONNonSuccess(_ context.Context, _ *fastly.GetDomainMetricsInput, dst any) error { + msg := []byte(`{"status":"error","data":[]}`) + return json.Unmarshal(msg, dst) +} + +func getDomainMetricsNonSuccess(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return &fastly.DomainInspector{ + Status: fastly.ToPointer("error"), + }, nil +} + +func getDomainMetricsAssertStart(want time.Time) func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return func(_ context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + if i.Start == nil { + return nil, fmt.Errorf("expected Start to be set, got nil") + } + if !i.Start.Equal(want) { + return nil, fmt.Errorf("expected Start %v, got %v", want, *i.Start) + } + return domainMetricsOKResult() + } +} + +func getDomainMetricsAssertEnd(want time.Time) func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return func(_ context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + if i.End == nil { + return nil, fmt.Errorf("expected End to be set, got nil") + } + if !i.End.Equal(want) { + return nil, fmt.Errorf("expected End %v, got %v", want, *i.End) + } + return domainMetricsOKResult() + } +} + +func getDomainMetricsError(_ context.Context, _ *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return nil, errTest +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 8569f3ee8..7b23f0e49 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -235,6 +235,9 @@ type API struct { GetUsageFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) GetUsageByServiceFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) + GetDomainMetricsForServiceFn func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) + GetDomainMetricsForServiceJSONFn func(context.Context, *fastly.GetDomainMetricsInput, any) error + CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) @@ -1339,6 +1342,16 @@ func (m API) GetUsageByService(ctx context.Context, i *fastly.GetUsageInput) (*f return m.GetUsageByServiceFn(ctx, i) } +// GetDomainMetricsForService implements Interface. +func (m API) GetDomainMetricsForService(ctx context.Context, i *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) { + return m.GetDomainMetricsForServiceFn(ctx, i) +} + +// GetDomainMetricsForServiceJSON implements Interface. +func (m API) GetDomainMetricsForServiceJSON(ctx context.Context, i *fastly.GetDomainMetricsInput, dst any) error { + return m.GetDomainMetricsForServiceJSONFn(ctx, i, dst) +} + // CreateManagedLogging implements Interface. func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(ctx, i) From 8358e81ac5a66039399a4da035e0b80e0b8bacfb Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 17:30:57 +0100 Subject: [PATCH 06/11] feat(stats): add stats origin-inspector subcommand Adds `stats origin-inspector` for viewing origin-level metrics for a Fastly service. Supports service ID/name lookup, time range filtering (RFC3339/Unix), downsampling, host/datacenter/region filtering, group-by dimensions, pagination, and JSON output. --- CHANGELOG.md | 1 + pkg/api/interface.go | 2 + pkg/commands/commands.go | 2 + pkg/commands/stats/origin_inspector.go | 177 +++++++++++++++++ pkg/commands/stats/origin_inspector_test.go | 199 ++++++++++++++++++++ pkg/mock/api.go | 20 +- 6 files changed, 396 insertions(+), 5 deletions(-) create mode 100644 pkg/commands/stats/origin_inspector.go create mode 100644 pkg/commands/stats/origin_inspector_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f496150c..858ac42e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. - feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. - feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. +- feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index cdb0f5824..d0d357462 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -245,6 +245,8 @@ type Interface interface { GetUsageByService(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) GetDomainMetricsForService(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) GetDomainMetricsForServiceJSON(context.Context, *fastly.GetDomainMetricsInput, any) error + GetOriginMetricsForService(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) + GetOriginMetricsForServiceJSON(context.Context, *fastly.GetOriginMetricsInput, any) error CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index bd6ac3a4f..5ec92f015 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -707,6 +707,7 @@ func Define( // nolint:revive // function-length statsAggregate := stats.NewAggregateCommand(statsCmdRoot.CmdClause, data) statsDomainInspector := stats.NewDomainInspectorCommand(statsCmdRoot.CmdClause, data) statsHistorical := stats.NewHistoricalCommand(statsCmdRoot.CmdClause, data) + statsOriginInspector := stats.NewOriginInspectorCommand(statsCmdRoot.CmdClause, data) statsRealtime := stats.NewRealtimeCommand(statsCmdRoot.CmdClause, data) statsRegions := stats.NewRegionsCommand(statsCmdRoot.CmdClause, data) statsUsage := stats.NewUsageCommand(statsCmdRoot.CmdClause, data) @@ -1542,6 +1543,7 @@ func Define( // nolint:revive // function-length statsAggregate, statsDomainInspector, statsHistorical, + statsOriginInspector, statsRealtime, statsRegions, statsUsage, diff --git a/pkg/commands/stats/origin_inspector.go b/pkg/commands/stats/origin_inspector.go new file mode 100644 index 000000000..162ce77d4 --- /dev/null +++ b/pkg/commands/stats/origin_inspector.go @@ -0,0 +1,177 @@ +package stats + +import ( + "context" + "encoding/json" + "fmt" + "io" + "time" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/argparser" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// OriginInspectorCommand exposes the Origin Inspector API. +type OriginInspectorCommand struct { + argparser.Base + + cursor string + datacenters []string + downsample string + formatFlag string + from string + groupBy []string + hosts []string + limit int + metrics []string + regions []string + serviceName argparser.OptionalServiceNameID + to string +} + +// NewOriginInspectorCommand is the "stats origin-inspector" subcommand. +func NewOriginInspectorCommand(parent argparser.Registerer, g *global.Data) *OriginInspectorCommand { + var c OriginInspectorCommand + c.Globals = g + + c.CmdClause = parent.Command("origin-inspector", "View origin metrics for a Fastly service") + c.RegisterFlag(argparser.StringFlagOpts{ + Name: argparser.FlagServiceIDName, + Description: argparser.FlagServiceIDDesc, + Dst: &g.Manifest.Flag.ServiceID, + Short: 's', + }) + c.RegisterFlag(argparser.StringFlagOpts{ + Action: c.serviceName.Set, + Name: argparser.FlagServiceName, + Description: argparser.FlagServiceNameDesc, + Dst: &c.serviceName.Value, + }) + + c.CmdClause.Flag("from", "Start time (RFC3339 or Unix timestamp)").StringVar(&c.from) + c.CmdClause.Flag("to", "End time (RFC3339 or Unix timestamp)").StringVar(&c.to) + c.CmdClause.Flag("downsample", "Sample window (minute/hour/day)").EnumVar(&c.downsample, "minute", "hour", "day") + c.CmdClause.Flag("metric", "Metrics to retrieve (repeatable, up to 10)").StringsVar(&c.metrics) + c.CmdClause.Flag("host", "Filter by origin host (repeatable)").StringsVar(&c.hosts) + c.CmdClause.Flag("datacenter", "Filter by POP (repeatable)").StringsVar(&c.datacenters) + c.CmdClause.Flag("region", "Filter by region (repeatable)").StringsVar(&c.regions) + c.CmdClause.Flag("group-by", "Dimensions to group by (repeatable)").StringsVar(&c.groupBy) + c.CmdClause.Flag("limit", "Max entries to return").IntVar(&c.limit) + c.CmdClause.Flag("cursor", "Pagination cursor from a previous response").StringVar(&c.cursor) + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *OriginInspectorCommand) Exec(_ io.Reader, out io.Writer) error { + serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog) + if err != nil { + return err + } + if c.Globals.Verbose() { + argparser.DisplayServiceID(serviceID, flag, source, out) + } + + input := fastly.GetOriginMetricsInput{ + ServiceID: serviceID, + Datacenters: c.datacenters, + GroupBy: c.groupBy, + Hosts: c.hosts, + Metrics: c.metrics, + Regions: c.regions, + } + if c.cursor != "" { + input.Cursor = &c.cursor + } + if c.downsample != "" { + input.Downsample = &c.downsample + } + if c.from != "" { + t, err := parseTime(c.from) + if err != nil { + return fmt.Errorf("invalid --from value: %w", err) + } + input.Start = &t + } + if c.to != "" { + t, err := parseTime(c.to) + if err != nil { + return fmt.Errorf("invalid --to value: %w", err) + } + input.End = &t + } + if c.limit > 0 { + input.Limit = &c.limit + } + + switch c.formatFlag { + case "json": + var envelope struct { + Status *string `json:"status"` + } + var raw json.RawMessage + if err := c.Globals.APIClient.GetOriginMetricsForServiceJSON(context.TODO(), &input, &raw); err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return err + } + if fastly.ToValue(envelope.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(envelope.Status)) + } + _, err := out.Write(raw) + if err != nil { + return err + } + fmt.Fprintln(out) + return nil + + default: + resp, err := c.Globals.APIClient.GetOriginMetricsForService(context.TODO(), &input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + if fastly.ToValue(resp.Status) != statusSuccess { + return fmt.Errorf("non-success response: %s", fastly.ToValue(resp.Status)) + } + return writeOriginInspector(out, resp) + } +} + +func writeOriginInspector(out io.Writer, resp *fastly.OriginInspector) error { + if resp.Meta != nil { + if resp.Meta.Start != nil { + text.Output(out, "Start: %s", *resp.Meta.Start) + } + if resp.Meta.End != nil { + text.Output(out, "End: %s", *resp.Meta.End) + } + fmt.Fprintln(out, "---") + } + for _, d := range resp.Data { + if d.Dimensions != nil { + for k, v := range d.Dimensions { + text.Output(out, "%s: %s", k, v) + } + } + for _, v := range d.Values { + if v.Timestamp != nil { + text.Output(out, " Timestamp: %s", time.Unix(int64(*v.Timestamp), 0).UTC()) //nolint:gosec // timestamp won't overflow + } + text.Output(out, " Responses: %d", fastly.ToValue(v.Responses)) + text.Output(out, " Status 2xx: %d", fastly.ToValue(v.Status2xx)) + text.Output(out, " Status 4xx: %d", fastly.ToValue(v.Status4xx)) + text.Output(out, " Status 5xx: %d", fastly.ToValue(v.Status5xx)) + } + } + if resp.Meta != nil && resp.Meta.NextCursor != nil { + text.Output(out, "Next cursor: %s", *resp.Meta.NextCursor) + } + return nil +} diff --git a/pkg/commands/stats/origin_inspector_test.go b/pkg/commands/stats/origin_inspector_test.go new file mode 100644 index 000000000..ee60d4276 --- /dev/null +++ b/pkg/commands/stats/origin_inspector_test.go @@ -0,0 +1,199 @@ +package stats_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "testing" + "time" + + "github.com/fastly/go-fastly/v13/fastly" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +func TestOriginInspector(t *testing.T) { + args := testutil.SplitArgs + scenarios := []struct { + name string + args []string + api mock.API + wantError string + wantOutput string + }{ + { + name: "success table", + args: args("stats origin-inspector --service-id 123"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsOK, + }, + wantOutput: "Responses:", + }, + { + name: "success json", + args: args("stats origin-inspector --service-id 123 --format=json"), + api: mock.API{ + GetOriginMetricsForServiceJSONFn: getOriginMetricsJSONOK, + }, + wantOutput: "status", + }, + { + name: "non-success status", + args: args("stats origin-inspector --service-id 123"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsNonSuccess, + }, + wantError: "non-success response", + }, + { + name: "missing service ID", + args: args("stats origin-inspector"), + api: mock.API{}, + wantError: "error reading service", + }, + { + name: "non-success status json", + args: args("stats origin-inspector --service-id 123 --format=json"), + api: mock.API{ + GetOriginMetricsForServiceJSONFn: getOriginMetricsJSONNonSuccess, + }, + wantError: "non-success response", + }, + { + name: "api error", + args: args("stats origin-inspector --service-id 123"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsError, + }, + wantError: errTest.Error(), + }, + { + name: "from RFC3339 maps to Start", + args: args("stats origin-inspector --service-id 123 --from 2024-01-15T10:00:00Z"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)), + }, + wantOutput: "Responses:", + }, + { + name: "from Unix epoch maps to Start", + args: args("stats origin-inspector --service-id 123 --from 1705312800"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsAssertStart(time.Unix(1705312800, 0)), + }, + wantOutput: "Responses:", + }, + { + name: "to RFC3339 maps to End", + args: args("stats origin-inspector --service-id 123 --to 2024-01-15T11:00:00Z"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsAssertEnd(time.Date(2024, 1, 15, 11, 0, 0, 0, time.UTC)), + }, + wantOutput: "Responses:", + }, + { + name: "from invalid format error", + args: args("stats origin-inspector --service-id 123 --from not-a-time"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsOK, + }, + wantError: "invalid --from value", + }, + { + name: "to invalid format error", + args: args("stats origin-inspector --service-id 123 --to not-a-time"), + api: mock.API{ + GetOriginMetricsForServiceFn: getOriginMetricsOK, + }, + wantError: "invalid --to value", + }, + } + for _, tc := range scenarios { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + app.Init = func(_ []string, _ io.Reader) (*global.Data, error) { + opts := testutil.MockGlobalData(tc.args, &stdout) + opts.APIClientFactory = mock.APIClient(tc.api) + return opts, nil + } + err := app.Run(tc.args, nil) + testutil.AssertErrorContains(t, err, tc.wantError) + testutil.AssertStringContains(t, stdout.String(), tc.wantOutput) + }) + } +} + +func originMetricsOKResult() (*fastly.OriginInspector, error) { + return &fastly.OriginInspector{ + Status: fastly.ToPointer("success"), + Meta: &fastly.OriginMeta{ + Start: fastly.ToPointer("2024-01-15T10:00:00Z"), + End: fastly.ToPointer("2024-01-15T11:00:00Z"), + }, + Data: []*fastly.OriginData{ + { + Values: []*fastly.OriginMetrics{ + { + Responses: fastly.ToPointer(uint64(200)), + Status2xx: fastly.ToPointer(uint64(180)), + Status4xx: fastly.ToPointer(uint64(15)), + Status5xx: fastly.ToPointer(uint64(5)), + }, + }, + }, + }, + }, nil +} + +func getOriginMetricsOK(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return originMetricsOKResult() +} + +func getOriginMetricsJSONOK(_ context.Context, _ *fastly.GetOriginMetricsInput, dst any) error { + msg := []byte(`{"status":"success","data":[]}`) + return json.Unmarshal(msg, dst) +} + +func getOriginMetricsJSONNonSuccess(_ context.Context, _ *fastly.GetOriginMetricsInput, dst any) error { + msg := []byte(`{"status":"error","data":[]}`) + return json.Unmarshal(msg, dst) +} + +func getOriginMetricsNonSuccess(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return &fastly.OriginInspector{ + Status: fastly.ToPointer("error"), + }, nil +} + +func getOriginMetricsAssertStart(want time.Time) func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return func(_ context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + if i.Start == nil { + return nil, fmt.Errorf("expected Start to be set, got nil") + } + if !i.Start.Equal(want) { + return nil, fmt.Errorf("expected Start %v, got %v", want, *i.Start) + } + return originMetricsOKResult() + } +} + +func getOriginMetricsAssertEnd(want time.Time) func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return func(_ context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + if i.End == nil { + return nil, fmt.Errorf("expected End to be set, got nil") + } + if !i.End.Equal(want) { + return nil, fmt.Errorf("expected End %v, got %v", want, *i.End) + } + return originMetricsOKResult() + } +} + +func getOriginMetricsError(_ context.Context, _ *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return nil, errTest +} diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 7b23f0e49..51195b46e 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -230,13 +230,13 @@ type API struct { GetRegionsFn func(context.Context) (*fastly.RegionsResponse, error) GetStatsJSONFn func(context.Context, *fastly.GetStatsInput, any) error - GetAggregateJSONFn func(context.Context, *fastly.GetAggregateInput, any) error - - GetUsageFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) - GetUsageByServiceFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) - + GetAggregateJSONFn func(context.Context, *fastly.GetAggregateInput, any) error + GetUsageFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageResponse, error) + GetUsageByServiceFn func(context.Context, *fastly.GetUsageInput) (*fastly.UsageByServiceResponse, error) GetDomainMetricsForServiceFn func(context.Context, *fastly.GetDomainMetricsInput) (*fastly.DomainInspector, error) GetDomainMetricsForServiceJSONFn func(context.Context, *fastly.GetDomainMetricsInput, any) error + GetOriginMetricsForServiceFn func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) + GetOriginMetricsForServiceJSONFn func(context.Context, *fastly.GetOriginMetricsInput, any) error CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) @@ -1352,6 +1352,16 @@ func (m API) GetDomainMetricsForServiceJSON(ctx context.Context, i *fastly.GetDo return m.GetDomainMetricsForServiceJSONFn(ctx, i, dst) } +// GetOriginMetricsForService implements Interface. +func (m API) GetOriginMetricsForService(ctx context.Context, i *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error) { + return m.GetOriginMetricsForServiceFn(ctx, i) +} + +// GetOriginMetricsForServiceJSON implements Interface. +func (m API) GetOriginMetricsForServiceJSON(ctx context.Context, i *fastly.GetOriginMetricsInput, dst any) error { + return m.GetOriginMetricsForServiceJSONFn(ctx, i, dst) +} + // CreateManagedLogging implements Interface. func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) { return m.CreateManagedLoggingFn(ctx, i) From 05cb196d564ad0e05c3de3199e92ab338f680a6c Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Thu, 5 Mar 2026 17:31:12 +0100 Subject: [PATCH 07/11] chore(stats): update root command description Reflects the newly added subcommands in the stats help text. --- pkg/commands/stats/root.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/commands/stats/root.go b/pkg/commands/stats/root.go index ac0e1d720..37e13caca 100644 --- a/pkg/commands/stats/root.go +++ b/pkg/commands/stats/root.go @@ -19,7 +19,7 @@ const CommandName = "stats" func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { var c RootCommand c.Globals = g - c.CmdClause = parent.Command(CommandName, "View historical and realtime statistics for a Fastly service") + c.CmdClause = parent.Command(CommandName, "View statistics for Fastly services: historical, realtime, aggregate, usage, domain and origin inspection") return &c } From 3ec2e727352ccfabfc45f1ae0f5e07f2dacc4477 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Sun, 8 Mar 2026 13:05:49 +0100 Subject: [PATCH 08/11] fix(stats): use API-provided hit_ratio No need to compute the hit rate, the API provides it. Also fixes the --field test cases to use the CLIScenario struct format. --- pkg/commands/stats/historical_test.go | 14 ++++++++------ pkg/commands/stats/template.go | 6 +----- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/pkg/commands/stats/historical_test.go b/pkg/commands/stats/historical_test.go index a0fb1f098..bbf6881da 100644 --- a/pkg/commands/stats/historical_test.go +++ b/pkg/commands/stats/historical_test.go @@ -33,14 +33,16 @@ func TestHistorical(t *testing.T) { WantOutput: historicalJSONOK, }, { - args: args("stats historical --service-id=123 --field=bandwidth"), - api: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, - wantOutput: historicalOK, + Name: "success with field filter", + Args: "--service-id=123 --field=bandwidth", + API: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + WantOutput: historicalOK, }, { - args: args("stats historical --service-id=123 --field=bandwidth --format=json"), - api: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, - wantOutput: historicalJSONOK, + Name: "success with field filter and json format", + Args: "--service-id=123 --field=bandwidth --format=json", + API: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + WantOutput: historicalJSONOK, }, } diff --git a/pkg/commands/stats/template.go b/pkg/commands/stats/template.go index ca9982fc8..1e642cc82 100644 --- a/pkg/commands/stats/template.go +++ b/pkg/commands/stats/template.go @@ -43,13 +43,9 @@ func fmtBlock(out io.Writer, service string, block statsResponseData) error { return err } - hitRate := 0.0 aggHits := fastly.ToValue(agg.Hits) aggMiss := fastly.ToValue(agg.Miss) aggErrs := fastly.ToValue(agg.Errors) - if aggHits > 0 { - hitRate = float64(aggHits-aggMiss-aggErrs) / float64(aggHits) - } // TODO: parse the JSON more strictly so this doesn't need to be dynamic. st, ok := block["start_time"].(float64) @@ -61,7 +57,7 @@ func fmtBlock(out io.Writer, service string, block statsResponseData) error { values := map[string]string{ "ServiceID": fmt.Sprintf("%30s", service), "StartTime": fmt.Sprintf("%30s", startTime), - "HitRate": fmt.Sprintf("%29.2f%%", hitRate*100), + "HitRate": fmt.Sprintf("%29.2f%%", fastly.ToValue(agg.HitRatio)*100), "AvgHitTime": fmt.Sprintf("%28.2f\u00b5s", fastly.ToValue(agg.HitsTime)*1000), "AvgMissTime": fmt.Sprintf("%28.2f\u00b5s", fastly.ToValue(agg.MissTime)*1000), From 53e101ad7a13448c72a94c666b48df01bb4637b3 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 9 Mar 2026 14:37:32 +0100 Subject: [PATCH 09/11] Add // Optional comments --- pkg/commands/stats/aggregate.go | 2 ++ pkg/commands/stats/domain_inspector.go | 2 ++ pkg/commands/stats/historical.go | 2 ++ pkg/commands/stats/origin_inspector.go | 2 ++ pkg/commands/stats/realtime.go | 2 ++ pkg/commands/stats/usage.go | 2 ++ 6 files changed, 12 insertions(+) diff --git a/pkg/commands/stats/aggregate.go b/pkg/commands/stats/aggregate.go index 5eacaab44..f1984c579 100644 --- a/pkg/commands/stats/aggregate.go +++ b/pkg/commands/stats/aggregate.go @@ -29,6 +29,8 @@ func NewAggregateCommand(parent argparser.Registerer, g *global.Data) *Aggregate c.Globals = g c.CmdClause = parent.Command("aggregate", "View aggregated stats across all services") + + // Optional. c.CmdClause.Flag("from", "Start time").StringVar(&c.from) c.CmdClause.Flag("to", "End time").StringVar(&c.to) c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") diff --git a/pkg/commands/stats/domain_inspector.go b/pkg/commands/stats/domain_inspector.go index 2f323e215..45492d324 100644 --- a/pkg/commands/stats/domain_inspector.go +++ b/pkg/commands/stats/domain_inspector.go @@ -39,6 +39,8 @@ func NewDomainInspectorCommand(parent argparser.Registerer, g *global.Data) *Dom c.Globals = g c.CmdClause = parent.Command("domain-inspector", "View domain metrics for a Fastly service") + + // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, diff --git a/pkg/commands/stats/historical.go b/pkg/commands/stats/historical.go index cca934978..cadc89e49 100644 --- a/pkg/commands/stats/historical.go +++ b/pkg/commands/stats/historical.go @@ -33,6 +33,8 @@ func NewHistoricalCommand(parent argparser.Registerer, g *global.Data) *Historic c.Globals = g c.CmdClause = parent.Command("historical", "View historical stats for a Fastly service") + + // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, diff --git a/pkg/commands/stats/origin_inspector.go b/pkg/commands/stats/origin_inspector.go index 162ce77d4..26350685f 100644 --- a/pkg/commands/stats/origin_inspector.go +++ b/pkg/commands/stats/origin_inspector.go @@ -38,6 +38,8 @@ func NewOriginInspectorCommand(parent argparser.Registerer, g *global.Data) *Ori c.Globals = g c.CmdClause = parent.Command("origin-inspector", "View origin metrics for a Fastly service") + + // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, diff --git a/pkg/commands/stats/realtime.go b/pkg/commands/stats/realtime.go index ef3e51ba3..2b0b97dfb 100644 --- a/pkg/commands/stats/realtime.go +++ b/pkg/commands/stats/realtime.go @@ -28,6 +28,8 @@ func NewRealtimeCommand(parent argparser.Registerer, g *global.Data) *RealtimeCo c.Globals = g c.CmdClause = parent.Command("realtime", "View realtime stats for a Fastly service") + + // Optional. c.RegisterFlag(argparser.StringFlagOpts{ Name: argparser.FlagServiceIDName, Description: argparser.FlagServiceIDDesc, diff --git a/pkg/commands/stats/usage.go b/pkg/commands/stats/usage.go index 44860710d..0d903f711 100644 --- a/pkg/commands/stats/usage.go +++ b/pkg/commands/stats/usage.go @@ -33,6 +33,8 @@ func NewUsageCommand(parent argparser.Registerer, g *global.Data) *UsageCommand c.Globals = g c.CmdClause = parent.Command("usage", "View usage stats (bandwidth, requests)") + + // Optional. c.CmdClause.Flag("from", "Start time").StringVar(&c.from) c.CmdClause.Flag("to", "End time").StringVar(&c.to) c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.by, "minute", "hour", "day") From fc1ca30ab12793ee45af5530d2d16e2e82124fa9 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 9 Mar 2026 15:42:22 +0100 Subject: [PATCH 10/11] Add link to the PR, for every changelog entry --- CHANGELOG.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 858ac42e1..89d1a3b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,14 @@ ### Breaking: ### Bug Fixes: -- fix(stats): `stats historical` now returns write errors instead of silently swallowing them. +- fix(stats): `stats historical` now returns write errors instead of silently swallowing them. [#1678](https://github.com/fastly/cli/pull/1678) ### Enhancements: -- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. -- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. -- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. -- feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. -- feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. +- feat(stats): add `--field` flag to `stats historical` to filter to a single stats field. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats aggregate` subcommand for cross-service aggregated stats. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats usage` subcommand for bandwidth/request usage, with `--by-service` breakdown. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats domain-inspector` subcommand for domain-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) +- feat(stats): add `stats origin-inspector` subcommand for origin-level metrics. [#1678](https://github.com/fastly/cli/pull/1678) ### Dependencies: - build(deps): `golang.org/x/net` from 0.50.0 to 0.51.0 ([#1674](https://github.com/fastly/cli/pull/1674)) From efde3ad987227f18748ba6cd51a7d355c97769eb Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 10 Mar 2026 17:41:30 +0100 Subject: [PATCH 11/11] Lint after golangci-lint update --- pkg/commands/stats/historical_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/commands/stats/historical_test.go b/pkg/commands/stats/historical_test.go index 1d674c80f..cd4038feb 100644 --- a/pkg/commands/stats/historical_test.go +++ b/pkg/commands/stats/historical_test.go @@ -35,13 +35,13 @@ func TestHistorical(t *testing.T) { { Name: "success with field filter", Args: "--service-id=123 --field=bandwidth", - API: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + API: &mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, WantOutput: historicalOK, }, { Name: "success with field filter and json format", Args: "--service-id=123 --field=bandwidth --format=json", - API: mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, + API: &mock.API{GetStatsJSONFn: getStatsJSONFieldOK}, WantOutput: historicalJSONOK, }, }