From c910597f2f4f6a7da08e753899d2b8ba0bd212d6 Mon Sep 17 00:00:00 2001 From: Philipp Schulte Date: Mon, 17 Aug 2026 14:55:23 -0400 Subject: [PATCH] feat(service/logging): add Log Explorer and Insights commands --- CHANGELOG.md | 2 + pkg/api/interface.go | 2 + pkg/commands/commands.go | 6 + pkg/commands/service/logging/doc.go | 3 +- pkg/commands/service/logging/insights/doc.go | 2 + .../service/logging/insights/insights_test.go | 249 ++++++++++++++++++ pkg/commands/service/logging/insights/root.go | 241 +++++++++++++++++ .../service/logging/logexplorer/doc.go | 2 + .../logging/logexplorer/logexplorer_test.go | 190 +++++++++++++ .../service/logging/logexplorer/root.go | 243 +++++++++++++++++ pkg/commands/service/logging/root.go | 2 +- pkg/mock/api.go | 12 + 12 files changed, 951 insertions(+), 3 deletions(-) create mode 100644 pkg/commands/service/logging/insights/doc.go create mode 100644 pkg/commands/service/logging/insights/insights_test.go create mode 100644 pkg/commands/service/logging/insights/root.go create mode 100644 pkg/commands/service/logging/logexplorer/doc.go create mode 100644 pkg/commands/service/logging/logexplorer/logexplorer_test.go create mode 100644 pkg/commands/service/logging/logexplorer/root.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 80729719f..368d97f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Enhancements: +- feat(service/logging): add Log Explorer and Insights commands ([#1887](https://github.com/fastly/cli/pull/1887)) + ### Dependencies: - build(deps): `golang.org/x/crypto` from 0.54.0 to 0.55.0 ([#1888](https://github.com/fastly/cli/pull/1888)) - build(deps): `golang.org/x/mod` from 0.38.0 to 0.39.0 ([#1888](https://github.com/fastly/cli/pull/1888)) diff --git a/pkg/api/interface.go b/pkg/api/interface.go index af61d0e80..107ddf707 100644 --- a/pkg/api/interface.go +++ b/pkg/api/interface.go @@ -251,6 +251,8 @@ type Interface interface { CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetLoggingEndpointErrors(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) + GetLogRecords(context.Context, *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) + GetLogInsights(context.Context, *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) GetGeneratedVCL(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index ede37695b..934379761 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -150,8 +150,10 @@ import ( serviceloggingheroku "github.com/fastly/cli/pkg/commands/service/logging/heroku" servicelogginghoneycomb "github.com/fastly/cli/pkg/commands/service/logging/honeycomb" servicelogginghttps "github.com/fastly/cli/pkg/commands/service/logging/https" + servicelogginginsights "github.com/fastly/cli/pkg/commands/service/logging/insights" serviceloggingkafka "github.com/fastly/cli/pkg/commands/service/logging/kafka" serviceloggingkinesis "github.com/fastly/cli/pkg/commands/service/logging/kinesis" + servicelogginglogexplorer "github.com/fastly/cli/pkg/commands/service/logging/logexplorer" serviceloggingloggly "github.com/fastly/cli/pkg/commands/service/logging/loggly" servicelogginglogshuttle "github.com/fastly/cli/pkg/commands/service/logging/logshuttle" serviceloggingnewrelic "github.com/fastly/cli/pkg/commands/service/logging/newrelic" @@ -639,6 +641,8 @@ func Define( // nolint:revive // function-length servicevclSnippetUpdate := servicevclsnippet.NewUpdateCommand(servicevclSnippetCmdRoot.CmdClause, data) serviceloggingCmdRoot := servicelogging.NewRootCommand(serviceCmdRoot.CmdClause, data) serviceloggingDebugCmd := serviceloggingdebug.NewDebugCommand(serviceloggingCmdRoot.CmdClause, data) + serviceloggingInsightsCmd := servicelogginginsights.NewInsightsCommand(serviceloggingCmdRoot.CmdClause, data) + serviceloggingLogExplorerCmd := servicelogginglogexplorer.NewLogExplorerCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingAzureblobCmdRoot := serviceloggingazureblob.NewRootCommand(serviceloggingCmdRoot.CmdClause, data) serviceloggingAzureblobCreate := serviceloggingazureblob.NewCreateCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) serviceloggingAzureblobDelete := serviceloggingazureblob.NewDeleteCommand(serviceloggingAzureblobCmdRoot.CmdClause, data) @@ -1326,6 +1330,8 @@ func Define( // nolint:revive // function-length kvstoreentryList, logtailCmdRoot, serviceloggingDebugCmd, + serviceloggingInsightsCmd, + serviceloggingLogExplorerCmd, serviceloggingAzureblobCmdRoot, serviceloggingAzureblobCreate, serviceloggingAzureblobDelete, diff --git a/pkg/commands/service/logging/doc.go b/pkg/commands/service/logging/doc.go index b217287b3..105721b09 100644 --- a/pkg/commands/service/logging/doc.go +++ b/pkg/commands/service/logging/doc.go @@ -1,3 +1,2 @@ -// Package logging contains commands to inspect and manipulate Fastly service -// logging endpoints. +// Package logging contains commands to inspect and manage Fastly service logging. package logging diff --git a/pkg/commands/service/logging/insights/doc.go b/pkg/commands/service/logging/insights/doc.go new file mode 100644 index 000000000..2c05fbc5b --- /dev/null +++ b/pkg/commands/service/logging/insights/doc.go @@ -0,0 +1,2 @@ +// Package insights contains the command for retrieving statistics from sampled logs. +package insights diff --git a/pkg/commands/service/logging/insights/insights_test.go b/pkg/commands/service/logging/insights/insights_test.go new file mode 100644 index 000000000..0fe601e66 --- /dev/null +++ b/pkg/commands/service/logging/insights/insights_test.go @@ -0,0 +1,249 @@ +package insights_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/fastly/go-fastly/v17/fastly" + + "github.com/fastly/cli/pkg/commands/service" + "github.com/fastly/cli/pkg/commands/service/logging" + "github.com/fastly/cli/pkg/commands/service/logging/insights" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + testServiceID = "123" + testStart = "2026-08-12T15:00:00Z" + testEnd = "2026-08-13T15:00:00Z" +) + +var errLogInsightsTest = errors.New("log insights test error") + +func TestLogInsights(t *testing.T) { + const visualization = "top-url-by-requests" + + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --start flag", + Args: fmt.Sprintf("--service-id %s --end %s --visualization %s", testServiceID, testEnd, visualization), + WantError: "required flag --start not provided", + }, + { + Name: "validate missing --end flag", + Args: fmt.Sprintf("--service-id %s --start %s --visualization %s", testServiceID, testStart, visualization), + WantError: "required flag --end not provided", + }, + { + Name: "validate missing --visualization flag", + Args: fmt.Sprintf("--service-id %s --start %s --end %s", testServiceID, testStart, testEnd), + WantError: "required flag --visualization not provided", + }, + { + Name: "validate invalid --visualization value", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization invalid", testServiceID, testStart, testEnd), + WantError: "enum value must be one of", + }, + { + Name: "validate missing service ID", + Args: fmt.Sprintf("--start %s --end %s --visualization %s", testStart, testEnd, visualization), + EnvVars: map[string]string{ + "FASTLY_SERVICE_ID": "", + }, + WantError: "error reading service", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization %s", testServiceID, testStart, testEnd, visualization), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsOK, + }, + WantOutputs: []string{ + "DIMENSIONS", + "VALUES", + "url=GET /health", + "request_percentage=0.5161290322580645", + }, + }, + { + Name: "validate status code dimension output", + Args: fmt.Sprintf( + "--service-id %s --start %s --end %s --visualization response-status-codes", + testServiceID, + testStart, + testEnd, + ), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsStatusCode, + }, + WantOutput: "status-code=200", + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization %s --json", testServiceID, testStart, testEnd, visualization), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsOK, + }, + WantOutputs: []string{ + `"url": "GET /health"`, + `"request_percentage": 0.5161290322580645`, + `"service_id": "123"`, + }, + }, + { + Name: "validate invalid --domain-exact-match value", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization %s --domain-exact-match invalid", testServiceID, testStart, testEnd, visualization), + WantError: "'domain-exact-match' flag must be one of the following [true, false]", + }, + { + Name: "validate optional request flags", + Args: fmt.Sprintf( + "--service-id %s --start %s --end %s --visualization %s --domain example.com --domain-exact-match=false --limit 5 --pops IAD,DFW", + testServiceID, + testStart, + testEnd, + visualization, + ), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsWithOptions, + }, + WantOutput: "No log insights found.", + }, + { + Name: "validate optional request flags with JSON output", + Args: fmt.Sprintf( + "--service-id %s --start %s --end %s --visualization %s --domain example.com --domain-exact-match=false --limit 5 --pops IAD,DFW --json", + testServiceID, + testStart, + testEnd, + visualization, + ), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsWithOptions, + }, + WantOutputs: []string{ + `"domain": "example.com"`, + `"domain_exact_match": false`, + `"pops": [`, + `"IAD"`, + `"DFW"`, + }, + }, + { + Name: "validate API error", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization %s", testServiceID, testStart, testEnd, visualization), + API: &mock.API{ + GetLogInsightsFn: getLogInsightsError, + }, + WantError: errLogInsightsTest.Error(), + }, + { + Name: "validate --verbose and --json are mutually exclusive", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --visualization %s --verbose --json", testServiceID, testStart, testEnd, visualization), + WantError: "invalid flag combination, --verbose and --json", + }, + } + + testutil.RunCLIScenarios( + t, + []string{service.CommandName, logging.CommandName, insights.CommandName}, + scenarios, + ) +} + +func getLogInsightsOK(_ context.Context, input *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) { + if input.ServiceID != testServiceID { + return nil, fmt.Errorf("expected service ID %q, got %q", testServiceID, input.ServiceID) + } + if input.Start != testStart { + return nil, fmt.Errorf("expected start %q, got %q", testStart, input.Start) + } + if input.End != testEnd { + return nil, fmt.Errorf("expected end %q, got %q", testEnd, input.End) + } + if input.Visualization != fastly.LogInsightsVisualizationTopURLByRequests { + return nil, fmt.Errorf("unexpected visualization %q", input.Visualization) + } + + return &fastly.LogInsightsResponse{ + Data: []*fastly.LogInsightsData{ + { + Dimensions: &fastly.LogInsightsDimensions{ + URL: fastly.ToPointer("GET /health"), + }, + Values: []*fastly.LogInsightsValue{ + { + RequestPercentage: fastly.ToPointer(0.5161290322580645), + }, + }, + }, + }, + Meta: &fastly.LogInsightsMeta{ + Filters: &fastly.LogInsightsFilters{ + ServiceID: fastly.ToPointer(testServiceID), + Start: fastly.ToPointer(testStart), + End: fastly.ToPointer(testEnd), + DomainExactMatch: fastly.ToPointer(true), + Limit: fastly.ToPointer(10), + }, + }, + }, nil +} + +func getLogInsightsStatusCode(_ context.Context, input *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) { + if input.Visualization != fastly.LogInsightsVisualizationResponseStatusCodes { + return nil, fmt.Errorf("unexpected visualization %q", input.Visualization) + } + + return &fastly.LogInsightsResponse{ + Data: []*fastly.LogInsightsData{ + { + Dimensions: &fastly.LogInsightsDimensions{ + StatusCode: fastly.ToPointer("200"), + }, + Values: []*fastly.LogInsightsValue{ + { + Rate: fastly.ToPointer(1.0), + }, + }, + }, + }, + }, nil +} + +func getLogInsightsWithOptions(_ context.Context, input *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) { + if input.Domain == nil || *input.Domain != "example.com" { + return nil, fmt.Errorf("expected domain example.com, got %v", input.Domain) + } + if input.DomainExactMatch == nil || *input.DomainExactMatch { + return nil, fmt.Errorf("expected domain exact match false, got %v", input.DomainExactMatch) + } + if input.Limit == nil || *input.Limit != 5 { + return nil, fmt.Errorf("expected limit 5, got %v", input.Limit) + } + if len(input.POPs) != 2 || input.POPs[0] != "IAD" || input.POPs[1] != "DFW" { + return nil, fmt.Errorf("expected POPs [IAD DFW], got %v", input.POPs) + } + + return &fastly.LogInsightsResponse{ + Data: []*fastly.LogInsightsData{}, + Meta: &fastly.LogInsightsMeta{ + Filters: &fastly.LogInsightsFilters{ + Domain: fastly.ToPointer("example.com"), + DomainExactMatch: fastly.ToPointer(false), + End: fastly.ToPointer(testEnd), + Limit: fastly.ToPointer(5), + POPs: []string{"IAD", "DFW"}, + ServiceID: fastly.ToPointer(testServiceID), + Start: fastly.ToPointer(testStart), + }, + }, + }, nil +} + +func getLogInsightsError(_ context.Context, _ *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) { + return nil, errLogInsightsTest +} diff --git a/pkg/commands/service/logging/insights/root.go b/pkg/commands/service/logging/insights/root.go new file mode 100644 index 000000000..7e93bbd22 --- /dev/null +++ b/pkg/commands/service/logging/insights/root.go @@ -0,0 +1,241 @@ +package insights + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/fastly/go-fastly/v17/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CommandName is the string used to invoke the Log Insights command. +const CommandName = "insights" + +// visualizationOptions is a string representation of the visualizations +// supported by go-fastly, suitable for use with the enum flag below. +var visualizationOptions = func() (visualizations []string) { + for _, visualization := range fastly.LogInsightsVisualizations { + visualizations = append(visualizations, string(visualization)) + } + return visualizations +}() + +// Command exposes the Log Insights API. +type Command struct { + argparser.Base + argparser.JSONOutput + + // Required. + start string + end string + visualization string + + // Optional. + serviceName argparser.OptionalServiceNameID + domain argparser.OptionalString + domainExactMatch argparser.OptionalString + limit argparser.OptionalInt + pops argparser.OptionalString +} + +// NewInsightsCommand returns a usable Log Insights command registered under the parent. +func NewInsightsCommand(parent argparser.Registerer, g *global.Data) *Command { + c := Command{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Retrieve statistics from sampled log records") + + // Required. + c.CmdClause.Flag("start", "Inclusive start time in RFC3339 format").Required().StringVar(&c.start) + c.CmdClause.Flag("end", "Exclusive end time in RFC3339 format").Required().StringVar(&c.end) + c.CmdClause.Flag("visualization", "Log Insights visualization to retrieve"). + Required(). + HintOptions(visualizationOptions...). + EnumVar(&c.visualization, visualizationOptions...) + + // 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("domain", "Limit data to the specified request domain").Action(c.domain.Set).StringVar(&c.domain.Value) + c.CmdClause.Flag("domain-exact-match", "Treat --domain as an exact match instead of a suffix match [true, false]").Action(c.domainExactMatch.Set).StringVar(&c.domainExactMatch.Value) + c.CmdClause.Flag("limit", "Maximum number of rows to return (up to 100)").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("pops", "Comma-separated list of Fastly POP codes").Action(c.pops.Set).StringVar(&c.pops.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the Log Insights API. +func (c *Command) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + 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.GetLogInsightsInput{ + ServiceID: serviceID, + Start: c.start, + End: c.end, + Visualization: fastly.LogInsightsVisualization(c.visualization), + } + if c.domain.WasSet { + input.Domain = &c.domain.Value + } + if c.domainExactMatch.WasSet { + domainExactMatch, err := argparser.ConvertBoolFromStringFlag(c.domainExactMatch.Value, "domain-exact-match") + if err != nil { + c.Globals.ErrLog.Add(err) + return err + } + input.DomainExactMatch = domainExactMatch + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.pops.WasSet { + input.POPs = splitPOPs(c.pops.Value) + } + + result, err := c.Globals.APIClient.GetLogInsights(context.TODO(), input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + + if ok, err := c.WriteJSON(out, result); ok { + return err + } + + printLogInsights(out, result) + return nil +} + +func splitPOPs(value string) []string { + var result []string + for _, pop := range strings.Split(value, ",") { + if pop = strings.TrimSpace(pop); pop != "" { + result = append(result, pop) + } + } + return result +} + +func printLogInsights(out io.Writer, result *fastly.LogInsightsResponse) { + if result == nil || len(result.Data) == 0 { + fmt.Fprintln(out, "No log insights found.") + return + } + + table := text.NewTable(out) + table.AddHeader("DIMENSIONS", "VALUES") + + for _, data := range result.Data { + if data == nil { + continue + } + + dimensions := formatLogInsightsDimensions(data.Dimensions) + if len(data.Values) == 0 { + table.AddLine(dimensions, "-") + continue + } + + for _, value := range data.Values { + table.AddLine(dimensions, formatLogInsightsValue(value)) + } + } + + table.Print() +} + +func formatLogInsightsDimensions(d *fastly.LogInsightsDimensions) string { + if d == nil { + return "-" + } + + var values []string + appendString := func(name string, value *string) { + if value != nil { + values = append(values, name+"="+*value) + } + } + + appendString("browser", d.Browser) + appendString("browser_version", d.BrowserVersion) + appendString("content_type", d.ContentType) + appendString("country", d.Country) + appendString("device", d.Device) + appendString("os", d.OS) + appendString("region", d.Region) + appendString("response", d.Response) + appendString("status-code", d.StatusCode) + appendString("url", d.URL) + + if len(values) == 0 { + return "-" + } + return strings.Join(values, ", ") +} + +func formatLogInsightsValue(v *fastly.LogInsightsValue) string { + if v == nil { + return "-" + } + + var values []string + appendFloat := func(name string, value *float64) { + if value != nil { + values = append(values, name+"="+strconv.FormatFloat(*value, 'g', -1, 64)) + } + } + + appendFloat("average_bandwidth_bytes", v.AverageBandwidthBytes) + appendFloat("average_response_time", v.AverageResponseTime) + appendFloat("bandwidth_percentage", v.BandwidthPercentage) + appendFloat("cache_hit_ratio", v.CacheHitRatio) + appendFloat("country_chr", v.CountryCHR) + appendFloat("country_error_rate", v.CountryErrorRate) + appendFloat("country_request_rate", v.CountryRequestRate) + appendFloat("miss_rate", v.MissRate) + appendFloat("p95_response_time", v.P95ResponseTime) + appendFloat("rate", v.Rate) + appendFloat("503_rate_per_url", v.Rate503PerURL) + appendFloat("rate_per_status", v.RatePerStatus) + appendFloat("rate_per_url", v.RatePerURL) + appendFloat("region_chr", v.RegionCHR) + appendFloat("region_error_rate", v.RegionErrorRate) + appendFloat("request_percentage", v.RequestPercentage) + appendFloat("response_time_percentage", v.ResponseTimePercentage) + + if len(values) == 0 { + return "-" + } + return strings.Join(values, ", ") +} diff --git a/pkg/commands/service/logging/logexplorer/doc.go b/pkg/commands/service/logging/logexplorer/doc.go new file mode 100644 index 000000000..4fa28b1fa --- /dev/null +++ b/pkg/commands/service/logging/logexplorer/doc.go @@ -0,0 +1,2 @@ +// Package logexplorer contains the command for retrieving sampled logs from the Log Explorer API. +package logexplorer diff --git a/pkg/commands/service/logging/logexplorer/logexplorer_test.go b/pkg/commands/service/logging/logexplorer/logexplorer_test.go new file mode 100644 index 000000000..12246f7e4 --- /dev/null +++ b/pkg/commands/service/logging/logexplorer/logexplorer_test.go @@ -0,0 +1,190 @@ +package logexplorer_test + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/fastly/go-fastly/v17/fastly" + + "github.com/fastly/cli/pkg/commands/service" + "github.com/fastly/cli/pkg/commands/service/logging" + "github.com/fastly/cli/pkg/commands/service/logging/logexplorer" + "github.com/fastly/cli/pkg/mock" + "github.com/fastly/cli/pkg/testutil" +) + +const ( + testServiceID = "123" + testStart = "2026-08-12T15:00:00Z" + testEnd = "2026-08-13T15:00:00Z" +) + +var errLogExplorerTest = errors.New("log explorer test error") + +func TestLogExplorer(t *testing.T) { + scenarios := []testutil.CLIScenario{ + { + Name: "validate missing --start flag", + Args: fmt.Sprintf("--service-id %s --end %s", testServiceID, testEnd), + WantError: "required flag --start not provided", + }, + { + Name: "validate missing --end flag", + Args: fmt.Sprintf("--service-id %s --start %s", testServiceID, testStart), + WantError: "required flag --end not provided", + }, + { + Name: "validate missing service ID", + Args: fmt.Sprintf("--start %s --end %s", testStart, testEnd), + EnvVars: map[string]string{ + "FASTLY_SERVICE_ID": "", + }, + WantError: "error reading service", + }, + { + Name: "validate API success", + Args: fmt.Sprintf("--service-id %s --start %s --end %s", testServiceID, testStart, testEnd), + API: &mock.API{ + GetLogRecordsFn: getLogRecordsOK, + }, + WantOutputs: []string{ + "TIMESTAMP", + "GET", + "example.com", + "/health", + "200", + "IAD", + "Next cursor: next-page", + }, + }, + { + Name: "validate optional --json flag", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --json", testServiceID, testStart, testEnd), + API: &mock.API{ + GetLogRecordsFn: getLogRecordsOK, + }, + WantOutputs: []string{ + `"request_path": "/health"`, + `"response_status": 200`, + `"next_cursor": "next-page"`, + }, + }, + { + Name: "validate optional request flags", + Args: fmt.Sprintf( + "--service-id %s --start %s --end %s --filter response_time,gte,0 --filter response_status,in,200,201 --limit 5 --cursor cursor-1", + testServiceID, + testStart, + testEnd, + ), + API: &mock.API{ + GetLogRecordsFn: getLogRecordsWithOptions, + }, + WantOutput: "No log records found.", + }, + { + Name: "validate malformed --filter", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --filter response_time,gte", testServiceID, testStart, testEnd), + API: &mock.API{}, + WantError: "invalid --filter value", + }, + { + Name: "validate unsupported --filter field", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --filter invalid,gte,0", testServiceID, testStart, testEnd), + API: &mock.API{}, + WantError: "field must be one of", + }, + { + Name: "validate unsupported --filter operator", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --filter response_time,invalid,0", testServiceID, testStart, testEnd), + API: &mock.API{}, + WantError: "operator must be one of", + }, + { + Name: "validate API error", + Args: fmt.Sprintf("--service-id %s --start %s --end %s", testServiceID, testStart, testEnd), + API: &mock.API{ + GetLogRecordsFn: getLogRecordsError, + }, + WantError: errLogExplorerTest.Error(), + }, + { + Name: "validate --verbose and --json are mutually exclusive", + Args: fmt.Sprintf("--service-id %s --start %s --end %s --verbose --json", testServiceID, testStart, testEnd), + WantError: "invalid flag combination, --verbose and --json", + }, + } + + testutil.RunCLIScenarios( + t, + []string{service.CommandName, logging.CommandName, logexplorer.CommandName}, + scenarios, + ) +} + +func getLogRecordsOK(_ context.Context, input *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) { + if input.ServiceID != testServiceID { + return nil, fmt.Errorf("expected service ID %q, got %q", testServiceID, input.ServiceID) + } + if input.Start != testStart { + return nil, fmt.Errorf("expected start %q, got %q", testStart, input.Start) + } + if input.End != testEnd { + return nil, fmt.Errorf("expected end %q, got %q", testEnd, input.End) + } + + return &fastly.LogRecordsResponse{ + Data: []*fastly.LogRecord{ + { + Timestamp: fastly.ToPointer("2026-08-13T14:30:23Z"), + RequestMethod: fastly.ToPointer("GET"), + RequestHost: fastly.ToPointer("example.com"), + RequestPath: fastly.ToPointer("/health"), + ResponseStatus: fastly.ToPointer(200), + FastlyPOP: fastly.ToPointer("IAD"), + IsCacheHit: fastly.ToPointer(true), + ResponseTime: fastly.ToPointer(0.093), + }, + }, + Meta: &fastly.LogExplorerMeta{ + NextCursor: fastly.ToPointer("next-page"), + }, + }, nil +} + +func getLogRecordsWithOptions(_ context.Context, input *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) { + if input.Limit == nil || *input.Limit != 5 { + return nil, fmt.Errorf("expected limit 5, got %v", input.Limit) + } + if input.NextCursor == nil || *input.NextCursor != "cursor-1" { + return nil, fmt.Errorf("expected next cursor cursor-1, got %v", input.NextCursor) + } + if len(input.Filters) != 2 { + return nil, fmt.Errorf("expected 2 filters, got %d", len(input.Filters)) + } + + first := input.Filters[0] + if first.Field != fastly.LogExplorerFilterFieldResponseTime || + first.Operator != fastly.LogExplorerFilterOperatorGTE || + first.Value != "0" { + return nil, fmt.Errorf("unexpected first filter: %#v", first) + } + + second := input.Filters[1] + if second.Field != fastly.LogExplorerFilterFieldResponseStatus || + second.Operator != fastly.LogExplorerFilterOperatorIn || + second.Value != "200,201" { + return nil, fmt.Errorf("unexpected second filter: %#v", second) + } + + return &fastly.LogRecordsResponse{ + Data: nil, + Meta: &fastly.LogExplorerMeta{}, + }, nil +} + +func getLogRecordsError(_ context.Context, _ *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) { + return nil, errLogExplorerTest +} diff --git a/pkg/commands/service/logging/logexplorer/root.go b/pkg/commands/service/logging/logexplorer/root.go new file mode 100644 index 000000000..4f19fe02f --- /dev/null +++ b/pkg/commands/service/logging/logexplorer/root.go @@ -0,0 +1,243 @@ +package logexplorer + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/fastly/go-fastly/v17/fastly" + + "github.com/fastly/cli/pkg/argparser" + fsterr "github.com/fastly/cli/pkg/errors" + "github.com/fastly/cli/pkg/global" + "github.com/fastly/cli/pkg/text" +) + +// CommandName is the string used to invoke the Log Explorer command. +const CommandName = "log-explorer" + +// logExplorerFilterFieldOptions is a string representation of the filter +// fields supported by go-fastly. +var logExplorerFilterFieldOptions = func() (fields []string) { + for _, field := range fastly.LogExplorerFilterFields { + fields = append(fields, string(field)) + } + return fields +}() + +// logExplorerFilterOperatorOptions is a string representation of the filter +// operators supported by go-fastly. +var logExplorerFilterOperatorOptions = func() (operators []string) { + for _, operator := range fastly.LogExplorerFilterOperators { + operators = append(operators, string(operator)) + } + return operators +}() + +// Command exposes the Log Explorer API. +type Command struct { + argparser.Base + argparser.JSONOutput + + // Required. + start string + end string + + // Optional. + serviceName argparser.OptionalServiceNameID + filters []string + limit argparser.OptionalInt + cursor argparser.OptionalString +} + +// NewLogExplorerCommand returns a usable Log Explorer command registered under the parent. +func NewLogExplorerCommand(parent argparser.Registerer, g *global.Data) *Command { + c := Command{ + Base: argparser.Base{ + Globals: g, + }, + } + + c.CmdClause = parent.Command(CommandName, "Retrieve sampled log records") + + // Required. + c.CmdClause.Flag("start", "Inclusive start time in RFC3339 format").Required().StringVar(&c.start) + c.CmdClause.Flag("end", "Exclusive end time in RFC3339 format").Required().StringVar(&c.end) + + // 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("filter", "Filter in FIELD,OPERATOR,VALUE format (repeatable)").StringsVar(&c.filters) + c.CmdClause.Flag("limit", "Maximum number of rows to return (up to 100)").Action(c.limit.Set).IntVar(&c.limit.Value) + c.CmdClause.Flag("cursor", "Pagination cursor from a previous response").Action(c.cursor.Set).StringVar(&c.cursor.Value) + c.RegisterFlagBool(c.JSONFlag()) + + return &c +} + +// Exec invokes the Log Explorer API. +func (c *Command) Exec(_ io.Reader, out io.Writer) error { + if c.Globals.Verbose() && c.JSONOutput.Enabled { + return fsterr.ErrInvalidVerboseJSONCombo + } + + 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) + } + + filters, err := parseLogExplorerFilters(c.filters) + if err != nil { + return err + } + + input := &fastly.GetLogRecordsInput{ + ServiceID: serviceID, + Start: c.start, + End: c.end, + Filters: filters, + } + if c.limit.WasSet { + input.Limit = &c.limit.Value + } + if c.cursor.WasSet { + input.NextCursor = &c.cursor.Value + } + + result, err := c.Globals.APIClient.GetLogRecords(context.TODO(), input) + if err != nil { + c.Globals.ErrLog.AddWithContext(err, map[string]any{"Service ID": serviceID}) + return err + } + + if ok, err := c.WriteJSON(out, result); ok { + return err + } + + printLogRecords(out, result) + return nil +} + +func parseLogExplorerFilters(filters []string) ([]fastly.LogExplorerFilter, error) { + result := make([]fastly.LogExplorerFilter, 0, len(filters)) + for _, filter := range filters { + parts := strings.SplitN(filter, ",", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("invalid --filter value %q: expected FIELD,OPERATOR,VALUE", filter) + } + + field := strings.TrimSpace(parts[0]) + operator := strings.TrimSpace(parts[1]) + if field == "" || operator == "" { + return nil, fmt.Errorf("invalid --filter value %q: field and operator must not be empty", filter) + } + if !containsLogExplorerOption(logExplorerFilterFieldOptions, field) { + return nil, fmt.Errorf( + "invalid --filter value %q: field must be one of [%s]", + filter, + strings.Join(logExplorerFilterFieldOptions, ", "), + ) + } + if !containsLogExplorerOption(logExplorerFilterOperatorOptions, operator) { + return nil, fmt.Errorf( + "invalid --filter value %q: operator must be one of [%s]", + filter, + strings.Join(logExplorerFilterOperatorOptions, ", "), + ) + } + + result = append(result, fastly.LogExplorerFilter{ + Field: fastly.LogExplorerFilterField(field), + Operator: fastly.LogExplorerFilterOperator(operator), + Value: strings.TrimSpace(parts[2]), + }) + } + return result, nil +} + +func containsLogExplorerOption(options []string, value string) bool { + for _, option := range options { + if option == value { + return true + } + } + return false +} + +func printLogRecords(out io.Writer, result *fastly.LogRecordsResponse) { + if result == nil || len(result.Data) == 0 { + fmt.Fprintln(out, "No log records found.") + printLogExplorerCursor(out, result) + return + } + + table := text.NewTable(out) + table.AddHeader("TIMESTAMP", "METHOD", "HOST", "PATH", "STATUS", "POP", "CACHE HIT", "RESPONSE TIME") + + for _, record := range result.Data { + if record == nil { + continue + } + table.AddLine( + logExplorerString(record.Timestamp), + logExplorerString(record.RequestMethod), + logExplorerString(record.RequestHost), + logExplorerString(record.RequestPath), + logExplorerInt(record.ResponseStatus), + logExplorerString(record.FastlyPOP), + logExplorerBool(record.IsCacheHit), + logExplorerFloat(record.ResponseTime), + ) + } + table.Print() + + printLogExplorerCursor(out, result) +} + +func printLogExplorerCursor(out io.Writer, result *fastly.LogRecordsResponse) { + if result != nil && result.Meta != nil && result.Meta.NextCursor != nil && *result.Meta.NextCursor != "" { + fmt.Fprintf(out, "\nNext cursor: %s\n", *result.Meta.NextCursor) + } +} + +func logExplorerString(v *string) string { + if v == nil { + return "-" + } + return *v +} + +func logExplorerInt(v *int) any { + if v == nil { + return "-" + } + return *v +} + +func logExplorerBool(v *bool) any { + if v == nil { + return "-" + } + return *v +} + +func logExplorerFloat(v *float64) any { + if v == nil { + return "-" + } + return *v +} diff --git a/pkg/commands/service/logging/root.go b/pkg/commands/service/logging/root.go index 4ec0cf9d1..61e3ef6e3 100644 --- a/pkg/commands/service/logging/root.go +++ b/pkg/commands/service/logging/root.go @@ -21,7 +21,7 @@ const CommandName = "logging" func NewRootCommand(parent argparser.Registerer, g *global.Data) *RootCommand { var c RootCommand c.Globals = g - c.CmdClause = parent.Command(CommandName, "Manipulate Fastly service version logging endpoints") + c.CmdClause = parent.Command(CommandName, "Inspect and manage Fastly service logging") return &c } diff --git a/pkg/mock/api.go b/pkg/mock/api.go index 023f5fa3f..03fcdd0c0 100644 --- a/pkg/mock/api.go +++ b/pkg/mock/api.go @@ -241,6 +241,8 @@ type API struct { CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error) GetLoggingEndpointErrorsFn func(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) + GetLogRecordsFn func(context.Context, *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) + GetLogInsightsFn func(context.Context, *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) @@ -1389,6 +1391,16 @@ func (m API) GetLoggingEndpointErrors(ctx context.Context, i *fastly.LoggingEndp return m.GetLoggingEndpointErrorsFn(ctx, i) } +// GetLogRecords implements Interface. +func (m API) GetLogRecords(ctx context.Context, i *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error) { + return m.GetLogRecordsFn(ctx, i) +} + +// GetLogInsights implements Interface. +func (m API) GetLogInsights(ctx context.Context, i *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error) { + return m.GetLogInsightsFn(ctx, i) +} + // GetGeneratedVCL implements Interface. func (m API) GetGeneratedVCL(ctx context.Context, i *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) { return m.GetGeneratedVCLFn(ctx, i)