diff --git a/CHANGELOG.md b/CHANGELOG.md index bbed479ad..0e276e622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,14 @@ ### Breaking: ### Bug Fixes: +- 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. [#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)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index f7b41ff5e..d0d357462 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -240,6 +240,13 @@ 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) + 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 d757bb2f6..5ec92f015 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -704,9 +704,13 @@ 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) + 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) tlsConfigCmdRoot := tlsconfig.NewRootCommand(app, data) tlsConfigDescribe := tlsconfig.NewDescribeCommand(tlsConfigCmdRoot.CmdClause, data) tlsConfigList := tlsconfig.NewListCommand(tlsConfigCmdRoot.CmdClause, data) @@ -1536,9 +1540,13 @@ func Define( // nolint:revive // function-length serviceVersionUpdate, ssoCmdRoot, statsCmdRoot, + statsAggregate, + statsDomainInspector, statsHistorical, + statsOriginInspector, statsRealtime, statsRegions, + statsUsage, tlsConfigCmdRoot, tlsConfigDescribe, tlsConfigList, diff --git a/pkg/commands/stats/aggregate.go b/pkg/commands/stats/aggregate.go new file mode 100644 index 000000000..f1984c579 --- /dev/null +++ b/pkg/commands/stats/aggregate.go @@ -0,0 +1,89 @@ +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") + + // 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") + 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/commands/stats/domain_inspector.go b/pkg/commands/stats/domain_inspector.go new file mode 100644 index 000000000..45492d324 --- /dev/null +++ b/pkg/commands/stats/domain_inspector.go @@ -0,0 +1,190 @@ +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") + + // Optional. + 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/commands/stats/historical.go b/pkg/commands/stats/historical.go index f85ff5436..cadc89e49 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 @@ -32,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, @@ -45,6 +48,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 +75,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 } @@ -96,20 +103,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 +141,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 diff --git a/pkg/commands/stats/historical_test.go b/pkg/commands/stats/historical_test.go index 1635ba5a6..cd4038feb 100644 --- a/pkg/commands/stats/historical_test.go +++ b/pkg/commands/stats/historical_test.go @@ -32,6 +32,18 @@ func TestHistorical(t *testing.T) { API: &mock.API{GetStatsJSONFn: getStatsJSONOK}, WantOutput: historicalJSONOK, }, + { + Name: "success with field filter", + Args: "--service-id=123 --field=bandwidth", + 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}, + WantOutput: historicalJSONOK, + }, } testutil.RunCLIScenarios(t, []string{root.CommandName, "historical"}, scenarios) @@ -69,7 +81,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 +98,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 } diff --git a/pkg/commands/stats/origin_inspector.go b/pkg/commands/stats/origin_inspector.go new file mode 100644 index 000000000..26350685f --- /dev/null +++ b/pkg/commands/stats/origin_inspector.go @@ -0,0 +1,179 @@ +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") + + // Optional. + 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/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/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 } 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), diff --git a/pkg/commands/stats/usage.go b/pkg/commands/stats/usage.go new file mode 100644 index 000000000..0d903f711 --- /dev/null +++ b/pkg/commands/stats/usage.go @@ -0,0 +1,198 @@ +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)") + + // 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") + 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 82b7920b3..51195b46e 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -230,6 +230,14 @@ 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) + 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) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) @@ -1319,6 +1327,41 @@ 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) +} + +// 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) +} + +// 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) +} + +// 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)