diff --git a/go.mod b/go.mod index 764f37cee..a27887a8d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/mholt/archiver v3.1.1+incompatible github.com/mholt/archiver/v3 v3.3.0 github.com/mitchellh/go-wordwrap v1.0.0 - github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/mitchellh/mapstructure v1.1.2 github.com/nicksnyder/go-i18n v1.10.1 // indirect github.com/pierrec/lz4 v2.3.0+incompatible // indirect github.com/segmentio/textio v1.2.0 diff --git a/pkg/api/interface.go b/pkg/api/interface.go index 51b2973f2..de6b60a14 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -95,7 +95,18 @@ type Interface interface { DeleteGCS(*fastly.DeleteGCSInput) error GetUser(*fastly.GetUserInput) (*fastly.User, error) + + GetRegions() (*fastly.RegionsResponse, error) + GetStatsJSON(*fastly.GetStatsInput, interface{}) error +} + +// RealtimeStatsInterface is the subset of go-fastly's realtime stats API used here. +type RealtimeStatsInterface interface { + GetRealtimeStatsJSON(*fastly.GetRealtimeStatsInput, interface{}) error } -// Interface assertion, to catch mismatches early. +// Ensure that fastly.Client satisfies Interface. var _ Interface = (*fastly.Client)(nil) + +// Ensure that fastly.RTSClient satisfies RealtimeStatsInterface. +var _ RealtimeStatsInterface = (*fastly.RTSClient)(nil) diff --git a/pkg/app/run.go b/pkg/app/run.go index 93a678e51..6cc29d62d 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -28,6 +28,7 @@ import ( "github.com/fastly/cli/pkg/logging/syslog" "github.com/fastly/cli/pkg/service" "github.com/fastly/cli/pkg/serviceversion" + "github.com/fastly/cli/pkg/stats" "github.com/fastly/cli/pkg/text" "github.com/fastly/cli/pkg/update" "github.com/fastly/cli/pkg/version" @@ -174,6 +175,11 @@ func Run(args []string, env config.Environment, file config.File, configFilePath gcsUpdate := gcs.NewUpdateCommand(gcsRoot.CmdClause, &globals) gcsDelete := gcs.NewDeleteCommand(gcsRoot.CmdClause, &globals) + statsRoot := stats.NewRootCommand(app, &globals) + statsRegions := stats.NewRegionsCommand(statsRoot.CmdClause, &globals) + statsHistorical := stats.NewHistoricalCommand(statsRoot.CmdClause, &globals) + statsRealtime := stats.NewRealtimeCommand(statsRoot.CmdClause, &globals) + commands := []common.Command{ configureRoot, whoamiRoot, @@ -273,6 +279,11 @@ func Run(args []string, env config.Environment, file config.File, configFilePath gcsDescribe, gcsUpdate, gcsDelete, + + statsRoot, + statsRegions, + statsHistorical, + statsRealtime, } // Handle parse errors and display contextal usage if possible. Due to bugs @@ -363,6 +374,11 @@ func Run(args []string, env config.Environment, file config.File, configFilePath return fmt.Errorf("error constructing Fastly API client: %w", err) } + globals.RTSClient, err = fastly.NewRealtimeStatsClientForEndpoint(token, fastly.DefaultRealtimeStatsEndpoint) + if err != nil { + return fmt.Errorf("error constructing Fastly realtime stats client: %w", err) + } + command, found := common.SelectCommand(name, commands) if !found { usage := Usage(args, app, out, ioutil.Discard) diff --git a/pkg/app/run_test.go b/pkg/app/run_test.go index 4c141e692..b666d4f8f 100644 --- a/pkg/app/run_test.go +++ b/pkg/app/run_test.go @@ -1,6 +1,7 @@ package app_test import ( + "bufio" "bytes" "io" "strings" @@ -29,12 +30,12 @@ func TestApplication(t *testing.T) { { name: "no args", args: nil, - wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n\n", + wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n", }, { name: "help flag only", args: []string{"--help"}, - wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n\n", + wantErr: helpDefault + "\nERROR: error parsing arguments: command not specified.\n", }, { name: "help argument only", @@ -65,16 +66,26 @@ func TestApplication(t *testing.T) { errors.Deduce(err).Print(&stderr) } - testutil.AssertString(t, testcase.wantOut, stdout.String()) + // Our flag package creates trailing space on + // some lines. Strip what we get so we don't + // need to maintain invisible spaces in + // wantOut/wantErr below. + testutil.AssertString(t, testcase.wantOut, stripTrailingSpace(stdout.String())) + testutil.AssertString(t, testcase.wantErr, stripTrailingSpace(stderr.String())) + }) + } +} - wantErrLines := strings.Split(testcase.wantErr, "\n") - outputErrLines := strings.Split(stderr.String(), "\n") +// stripTrailingSpace removes any trailing spaces from the multiline str. +func stripTrailingSpace(str string) string { + buf := bytes.NewBuffer(nil) - for i, line := range outputErrLines { - testutil.AssertString(t, strings.TrimSpace(wantErrLines[i]), strings.TrimSpace(line)) - } - }) + scan := bufio.NewScanner(strings.NewReader(str)) + for scan.Scan() { + buf.WriteString(strings.TrimRight(scan.Text(), " \t\r\n")) + buf.WriteString("\n") } + return buf.String() } var helpDefault = strings.TrimSpace(` @@ -101,6 +112,8 @@ COMMANDS backend Manipulate Fastly service version backends healthcheck Manipulate Fastly service version healthchecks logging Manipulate Fastly service version logging endpoints + stats View statistics (historical and realtime) for a Fastly + service `) + "\n\n" var helpService = strings.TrimSpace(` @@ -844,7 +857,7 @@ COMMANDS --auth-token=AUTH-TOKEN Use token based authentication (https://logentries.com/doc/input-token/) --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -852,7 +865,7 @@ COMMANDS placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1 - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute @@ -888,7 +901,7 @@ COMMANDS --auth-token=AUTH-TOKEN Use token based authentication (https://logentries.com/doc/input-token/) --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -896,7 +909,7 @@ COMMANDS placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1 - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute @@ -921,7 +934,7 @@ COMMANDS --version=VERSION Number of service version --address=ADDRESS A hostname or IPv4 address --port=PORT The port number - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -930,7 +943,7 @@ COMMANDS is set to 2 and in vcl_deliver if format_version is set to 1 --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute @@ -962,7 +975,7 @@ COMMANDS --new-name=NEW-NAME New name of the Papertrail logging object --address=ADDRESS A hostname or IPv4 address --port=PORT The port number - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -971,7 +984,7 @@ COMMANDS is set to 2 and in vcl_deliver if format_version is set to 1 --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute @@ -996,7 +1009,7 @@ COMMANDS --version=VERSION Number of service version --url=URL The URL to POST to --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -1004,11 +1017,11 @@ COMMANDS placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1 - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute - --message-type=MESSAGE-TYPE + --message-type=MESSAGE-TYPE How the message should be formatted. One of: classic (default), loggly, logplex or blank --placement=PLACEMENT Where in the generated VCL the logging call @@ -1039,7 +1052,7 @@ COMMANDS --new-name=NEW-NAME New name of the Sumologic logging object --url=URL The URL to POST to --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -1047,11 +1060,11 @@ COMMANDS placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1 - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute - --message-type=MESSAGE-TYPE + --message-type=MESSAGE-TYPE How the message should be formatted. One of: classic (default), loggly, logplex or blank --placement=PLACEMENT Where in the generated VCL the logging call @@ -1087,7 +1100,7 @@ COMMANDS --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when dumping logs (default 0, no compression) --format=FORMAT Apache style log formatting - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -1095,14 +1108,14 @@ COMMANDS placed by default in vcl_log if format_version is set to 2 and in vcl_deliver if format_version is set to 1 - --message-type=MESSAGE-TYPE + --message-type=MESSAGE-TYPE How the message should be formatted. One of: classic (default), loggly, logplex or blank - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute - --timestamp-format=TIMESTAMP-FORMAT + --timestamp-format=TIMESTAMP-FORMAT strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000") --placement=PLACEMENT Where in the generated VCL the logging call @@ -1141,7 +1154,7 @@ COMMANDS --period=PERIOD How frequently log files are finalized so they can be available for reading (in seconds, default 3600) - --format-version=FORMAT-VERSION + --format-version=FORMAT-VERSION The version of the custom logging format used for the configured endpoint. Can be either 2 (the default, version 2 log format) or 1 (the @@ -1152,11 +1165,11 @@ COMMANDS --gzip-level=GZIP-LEVEL What level of GZIP encoding to have when dumping logs (default 0, no compression) --format=FORMAT Apache style log formatting - --response-condition=RESPONSE-CONDITION + --response-condition=RESPONSE-CONDITION The name of an existing condition in the configured endpoint, or leave blank to always execute - --timestamp-format=TIMESTAMP-FORMAT + --timestamp-format=TIMESTAMP-FORMAT strftime specified timestamp formatting (default "%Y-%m-%dT%H:%M:%S.000") --placement=PLACEMENT Where in the generated VCL the logging call @@ -1170,6 +1183,27 @@ COMMANDS --version=VERSION Number of service version -n, --name=NAME The name of the GCS logging object + stats regions + List stats regions + + + stats historical --service-id=SERVICE-ID [] + View historical stats for a Fastly service + + -s, --service-id=SERVICE-ID Service ID + --from=FROM From time, accepted formats at + https://docs.fastly.com/api/stats#Range + --to=TO To time + --by=BY Aggregation period (minute/hour/day) + --region=REGION Filter by region ('stats regions' to list) + --format=FORMAT Output format (json) + + stats realtime --service-id=SERVICE-ID [] + View realtime stats for a Fastly service + + -s, --service-id=SERVICE-ID Service ID + --format=FORMAT Output format (json) + For help on a specific command, try e.g. fastly help configure diff --git a/pkg/config/data.go b/pkg/config/data.go index 749a3fb18..2791befd3 100644 --- a/pkg/config/data.go +++ b/pkg/config/data.go @@ -54,7 +54,8 @@ type Data struct { Env Environment Flag Flag - Client api.Interface // set by Run after parse + Client api.Interface + RTSClient api.RealtimeStatsInterface } // Token yields the Fastly API token. diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 89287b99f..cee8ba7de 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -86,6 +86,9 @@ type API struct { DeleteGCSFn func(*fastly.DeleteGCSInput) error GetUserFn func(*fastly.GetUserInput) (*fastly.User, error) + + GetRegionsFn func() (*fastly.RegionsResponse, error) + GetStatsJSONFn func(i *fastly.GetStatsInput, dst interface{}) error } // GetTokenSelf implements Interface. @@ -412,3 +415,13 @@ func (m API) DeleteGCS(i *fastly.DeleteGCSInput) error { func (m API) GetUser(i *fastly.GetUserInput) (*fastly.User, error) { return m.GetUserFn(i) } + +// GetRegions implements Interface. +func (m API) GetRegions() (*fastly.RegionsResponse, error) { + return m.GetRegionsFn() +} + +// GetStatsJSON implements Interface. +func (m API) GetStatsJSON(i *fastly.GetStatsInput, dst interface{}) error { + return m.GetStatsJSONFn(i, dst) +} diff --git a/pkg/stats/historical.go b/pkg/stats/historical.go new file mode 100644 index 000000000..272bf3c3e --- /dev/null +++ b/pkg/stats/historical.go @@ -0,0 +1,100 @@ +package stats + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/go-fastly/fastly" +) + +const statusSuccess = "success" + +// HistoricalCommand exposes the Historical Stats API. +type HistoricalCommand struct { + common.Base + manifest manifest.Data + + Input fastly.GetStatsInput + formatFlag string +} + +// NewHistoricalCommand is the "stats historical" subcommand. +func NewHistoricalCommand(parent common.Registerer, globals *config.Data) *HistoricalCommand { + var c HistoricalCommand + c.Globals = globals + + c.CmdClause = parent.Command("historical", "View historical stats for a Fastly service") + c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.manifest.Flag.ServiceID) + + c.CmdClause.Flag("from", "From time, accepted formats at https://docs.fastly.com/api/stats#Range").StringVar(&c.Input.From) + c.CmdClause.Flag("to", "To time").StringVar(&c.Input.To) + c.CmdClause.Flag("by", "Aggregation period (minute/hour/day)").EnumVar(&c.Input.By, "minute", "hour", "day") + c.CmdClause.Flag("region", "Filter by region ('stats regions' to list)").StringVar(&c.Input.Region) + + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *HistoricalCommand) Exec(in io.Reader, out io.Writer) error { + service, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + c.Input.Service = service + + var envelope statsResponse + err := c.Globals.Client.GetStatsJSON(&c.Input, &envelope) + if err != nil { + return err + } + + if envelope.Status != statusSuccess { + return fmt.Errorf("non-success response: %s", envelope.Msg) + } + + switch c.formatFlag { + case "json": + writeBlocksJSON(out, service, envelope.Data) + + default: + writeHeader(out, envelope.Meta) + writeBlocks(out, service, envelope.Data) + } + + return nil +} + +func writeHeader(out io.Writer, meta statsResponseMeta) { + fmt.Fprintf(out, "From: %s\n", meta.From) + fmt.Fprintf(out, "To: %s\n", meta.To) + fmt.Fprintf(out, "By: %s\n", meta.By) + fmt.Fprintf(out, "Region: %s\n", meta.Region) + fmt.Fprintf(out, "---\n") +} + +func writeBlocks(out io.Writer, service string, blocks []statsResponseData) error { + for _, block := range blocks { + if err := fmtBlock(out, service, block); err != nil { + return err + } + } + + return nil +} + +func writeBlocksJSON(out io.Writer, service string, blocks []statsResponseData) error { + for _, block := range blocks { + if err := json.NewEncoder(out).Encode(block); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/stats/historical_test.go b/pkg/stats/historical_test.go new file mode 100644 index 000000000..61aa14bff --- /dev/null +++ b/pkg/stats/historical_test.go @@ -0,0 +1,112 @@ +package stats_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/update" + "github.com/fastly/go-fastly/fastly" +) + +func TestHistorical(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"stats", "historical", "--service-id=123"}, + api: mock.API{GetStatsJSONFn: getStatsJSONOK}, + wantOutput: historicalOK, + }, + { + args: []string{"stats", "historical", "--service-id=123"}, + api: mock.API{GetStatsJSONFn: getStatsJSONError}, + wantError: errTest.Error(), + }, + { + args: []string{"stats", "historical", "--service-id=123", "--format=json"}, + api: mock.API{GetStatsJSONFn: getStatsJSONOK}, + wantOutput: historicalJSONOK, + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + configFileName = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, configFileName, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + }) + } +} + +var historicalOK = `From: Wed May 15 20:08:35 UTC 2013 +To: Thu May 16 20:08:35 UTC 2013 +By: day +Region: all +--- +Service ID: 123 +Start Time: 1970-01-01 00:00:00 +0000 UTC +-------------------------------------------------- +Hit Rate: 0.00% +Avg Hit Time: 0.00µs +Avg Miss Time: 0.00µs + +Request BW: 0 + Headers: 0 + Body: 0 + +Response BW: 0 + Headers: 0 + Body: 0 + +Requests: 0 + Hit: 0 + Miss: 0 + Pass: 0 + Synth: 0 + Error: 0 + Uncacheable: 0 +` + +var historicalJSONOK = `{"start_time":0} +` + +func getStatsJSONOK(i *fastly.GetStatsInput, o interface{}) 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 getStatsJSONError(i *fastly.GetStatsInput, o interface{}) error { + return errTest +} diff --git a/pkg/stats/obj.go b/pkg/stats/obj.go new file mode 100644 index 000000000..ac7e8f0da --- /dev/null +++ b/pkg/stats/obj.go @@ -0,0 +1,31 @@ +package stats + +// The structs in this file are similar to those in go-fastly, but +// intended for json use rather than mapstructure. + +type statsResponse struct { + Status string `json:"status"` + Msg string `json:"msg"` + Meta statsResponseMeta `json:"meta"` + + Data []statsResponseData `json:"data"` +} + +type statsResponseMeta struct { + From string `json:"from"` + To string `json:"to"` + By string `json:"by"` + Region string `json:"region"` +} + +type statsResponseData map[string]interface{} + +type realtimeResponse struct { + Timestamp uint64 `json:"timestamp"` + Data []realtimeResponseData `json:"data"` +} + +type realtimeResponseData struct { + Recorded float64 `json:"recorded"` + Aggregated statsResponseData `json:"aggregated"` +} diff --git a/pkg/stats/realtime.go b/pkg/stats/realtime.go new file mode 100644 index 000000000..0f87e4f36 --- /dev/null +++ b/pkg/stats/realtime.go @@ -0,0 +1,114 @@ +package stats + +import ( + "encoding/json" + "io" + + "github.com/fastly/cli/pkg/api" + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/compute/manifest" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/text" + "github.com/fastly/go-fastly/fastly" +) + +// RealtimeCommand exposes the Realtime Metrics API. +type RealtimeCommand struct { + common.Base + manifest manifest.Data + + formatFlag string +} + +// NewRealtimeCommand is the "stats realtime" subcommand. +func NewRealtimeCommand(parent common.Registerer, globals *config.Data) *RealtimeCommand { + var c RealtimeCommand + c.Globals = globals + + c.CmdClause = parent.Command("realtime", "View realtime stats for a Fastly service") + c.CmdClause.Flag("service-id", "Service ID").Short('s').Required().StringVar(&c.manifest.Flag.ServiceID) + + c.CmdClause.Flag("format", "Output format (json)").EnumVar(&c.formatFlag, "json") + + return &c +} + +// Exec implements the command interface. +func (c *RealtimeCommand) Exec(in io.Reader, out io.Writer) error { + service, source := c.manifest.ServiceID() + if source == manifest.SourceUndefined { + return errors.ErrNoServiceID + } + + switch c.formatFlag { + case "json": + if err := loopJSON(c.Globals.RTSClient, service, out); err != nil { + return err + } + + default: + if err := loopText(c.Globals.RTSClient, service, out); err != nil { + return err + } + } + + return nil +} + +func loopJSON(client api.RealtimeStatsInterface, service string, out io.Writer) error { + var timestamp uint64 + for { + var envelope struct { + Timestamp uint64 `json:"timestamp"` + Data []json.RawMessage `json:"data"` + } + + err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ + Service: service, + Timestamp: timestamp, + }, &envelope) + if err != nil { + text.Error(out, "fetching stats: %w", err) + continue + } + timestamp = envelope.Timestamp + + for _, block := range envelope.Data { + out.Write(block) + text.Break(out) + } + } +} + +func loopText(client api.RealtimeStatsInterface, service string, out io.Writer) error { + var timestamp uint64 + for { + var envelope realtimeResponse + + err := client.GetRealtimeStatsJSON(&fastly.GetRealtimeStatsInput{ + Service: service, + Timestamp: timestamp, + }, &envelope) + if err != nil { + text.Error(out, "fetching stats: %w", err) + continue + } + timestamp = envelope.Timestamp + + for _, block := range envelope.Data { + agg := block.Aggregated + + // FIXME: These are heavy-handed compatibility + // fixes for stats vs realtime, so we can use + // fmtBlock for both. + agg["start_time"] = block.Recorded + delete(agg, "miss_histogram") + + if err := fmtBlock(out, service, agg); err != nil { + text.Error(out, "formatting stats: %w", err) + continue + } + } + } +} diff --git a/pkg/stats/regions.go b/pkg/stats/regions.go new file mode 100644 index 000000000..9dd65cda0 --- /dev/null +++ b/pkg/stats/regions.go @@ -0,0 +1,37 @@ +package stats + +import ( + "fmt" + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/text" +) + +// RegionsCommand exposes the Stats Regions API. +type RegionsCommand struct { + common.Base +} + +// NewRegionsCommand returns a new command registered under parent. +func NewRegionsCommand(parent common.Registerer, globals *config.Data) *RegionsCommand { + var c RegionsCommand + c.Globals = globals + c.CmdClause = parent.Command("regions", "List stats regions") + return &c +} + +// Exec implements the command interface. +func (c *RegionsCommand) Exec(in io.Reader, out io.Writer) error { + resp, err := c.Globals.Client.GetRegions() + if err != nil { + return fmt.Errorf("fetching regions: %w", err) + } + + for _, region := range resp.Data { + text.Output(out, "%s", region) + } + + return nil +} diff --git a/pkg/stats/regions_test.go b/pkg/stats/regions_test.go new file mode 100644 index 000000000..2ce4daab9 --- /dev/null +++ b/pkg/stats/regions_test.go @@ -0,0 +1,66 @@ +package stats_test + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/fastly/cli/pkg/app" + "github.com/fastly/cli/pkg/config" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" + "github.com/fastly/cli/pkg/update" + "github.com/fastly/go-fastly/fastly" +) + +func TestRegions(t *testing.T) { + for _, testcase := range []struct { + args []string + api mock.API + wantError string + wantOutput string + }{ + { + args: []string{"stats", "regions"}, + api: mock.API{GetRegionsFn: getRegionsOK}, + wantOutput: "foo\nbar\nbaz\n", + }, + { + args: []string{"stats", "regions"}, + api: mock.API{GetRegionsFn: getRegionsError}, + wantError: errTest.Error(), + }, + } { + t.Run(strings.Join(testcase.args, " "), func(t *testing.T) { + var ( + args = testcase.args + env = config.Environment{} + file = config.File{} + configFileName = "/dev/null" + clientFactory = mock.APIClient(testcase.api) + httpClient = http.DefaultClient + versioner update.Versioner = nil + in io.Reader = nil + out bytes.Buffer + ) + err := app.Run(args, env, file, configFileName, clientFactory, httpClient, versioner, in, &out) + testutil.AssertErrorContains(t, err, testcase.wantError) + testutil.AssertStringContains(t, out.String(), testcase.wantOutput) + }) + } +} + +func getRegionsOK() (*fastly.RegionsResponse, error) { + return &fastly.RegionsResponse{ + Data: []string{"foo", "bar", "baz"}, + }, nil +} + +var errTest = errors.New("fixture error") + +func getRegionsError() (*fastly.RegionsResponse, error) { + return nil, errTest +} diff --git a/pkg/stats/root.go b/pkg/stats/root.go new file mode 100644 index 000000000..9d358f9ac --- /dev/null +++ b/pkg/stats/root.go @@ -0,0 +1,26 @@ +package stats + +import ( + "io" + + "github.com/fastly/cli/pkg/common" + "github.com/fastly/cli/pkg/config" +) + +// RootCommand dispatches all "stats" commands. +type RootCommand struct { + common.Base +} + +// NewRootCommand returns a new top level "stats" command. +func NewRootCommand(parent common.Registerer, globals *config.Data) *RootCommand { + var c RootCommand + c.Globals = globals + c.CmdClause = parent.Command("stats", "View statistics (historical and realtime) for a Fastly service") + return &c +} + +// Exec implements the command interface. +func (c *RootCommand) Exec(in io.Reader, out io.Writer) error { + panic("unreachable") +} diff --git a/pkg/stats/template.go b/pkg/stats/template.go new file mode 100644 index 000000000..597b22381 --- /dev/null +++ b/pkg/stats/template.go @@ -0,0 +1,76 @@ +package stats + +import ( + "fmt" + "io" + "text/template" + "time" + + "github.com/fastly/go-fastly/fastly" + "github.com/mitchellh/mapstructure" +) + +var blockTemplate = template.Must(template.New("stats_block").Parse( + `Service ID: {{ .ServiceID }} +Start Time: {{ .StartTime }} +-------------------------------------------------- +Hit Rate: {{ .HitRate }} +Avg Hit Time: {{ .AvgHitTime }} +Avg Miss Time: {{ .AvgMissTime }} + +Request BW: {{ .RequestBytes }} + Headers: {{ .RequestHeaderBytes }} + Body: {{ .RequestBodyBytes }} + +Response BW: {{ .ResponseBytes }} + Headers: {{ .ResponseHeaderBytes }} + Body: {{ .ResponseBodyBytes }} + +Requests: {{ .RequestCount }} + Hit: {{ .Hits }} + Miss: {{ .Miss }} + Pass: {{ .Pass }} + Synth: {{ .Synth }} + Error: {{ .Errors }} + Uncacheable: {{ .Uncacheable }} + +`)) + +func fmtBlock(out io.Writer, service string, block statsResponseData) error { + var agg fastly.Stats + if err := mapstructure.Decode(block, &agg); err != nil { + return err + } + + hitRate := 0.0 + if agg.Hits > 0 { + hitRate = float64((agg.Hits - agg.Miss - agg.Errors)) / float64(agg.Hits) + } + + // TODO: parse the JSON more strictly so this doesn't need to be dynamic. + startTime := time.Unix(int64(block["start_time"].(float64)), 0).UTC() + + values := map[string]string{ + "ServiceID": fmt.Sprintf("%30s", service), + "StartTime": fmt.Sprintf("%30s", startTime), + "HitRate": fmt.Sprintf("%29.2f%%", hitRate*100), + "AvgHitTime": fmt.Sprintf("%28.2f\u00b5s", agg.HitsTime*1000), + "AvgMissTime": fmt.Sprintf("%28.2f\u00b5s", agg.MissTime*1000), + + "RequestBytes": fmt.Sprintf("%30d", agg.RequestHeaderBytes+agg.RequestBodyBytes), + "RequestHeaderBytes": fmt.Sprintf("%30d", agg.RequestHeaderBytes), + "RequestBodyBytes": fmt.Sprintf("%30d", agg.RequestBodyBytes), + "ResponseBytes": fmt.Sprintf("%30d", agg.ResponseHeaderBytes+agg.ResponseBodyBytes), + "ResponseHeaderBytes": fmt.Sprintf("%30d", agg.ResponseHeaderBytes), + "ResponseBodyBytes": fmt.Sprintf("%30d", agg.ResponseBodyBytes), + + "RequestCount": fmt.Sprintf("%30d", agg.Requests), + "Hits": fmt.Sprintf("%30d", agg.Hits), + "Miss": fmt.Sprintf("%30d", agg.Miss), + "Pass": fmt.Sprintf("%30d", agg.Pass), + "Synth": fmt.Sprintf("%30d", agg.Synth), + "Errors": fmt.Sprintf("%30d", agg.Errors), + "Uncacheable": fmt.Sprintf("%30d", agg.Uncachable)} + + return blockTemplate.Execute(out, values) +}