Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Enhancements:

- feat(service/logging): add Log Explorer and Insights commands ([#1887](https://github.com/fastly/cli/pull/1887))

### Dependencies:

## [v16.0.0](https://github.com/fastly/cli/releases/tag/v16.0.0) (2026-08-13)
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@

CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error)
GetLoggingEndpointErrors(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error)
GetLogRecords(context.Context, *fastly.GetLogRecordsInput) (*fastly.LogRecordsResponse, error)

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Lint (pinned golangci-lint version)

undefined: fastly.LogRecordsResponse

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Lint (pinned golangci-lint version)

undefined: fastly.GetLogRecordsInput

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

undefined: fastly.LogRecordsResponse

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

undefined: fastly.GetLogRecordsInput

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / lint-latest (informational)

undefined: fastly.LogRecordsResponse

Check failure on line 254 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / lint-latest (informational)

undefined: fastly.GetLogRecordsInput
GetLogInsights(context.Context, *fastly.GetLogInsightsInput) (*fastly.LogInsightsResponse, error)

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Lint (pinned golangci-lint version)

undefined: fastly.LogInsightsResponse

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Lint (pinned golangci-lint version)

undefined: fastly.GetLogInsightsInput

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

undefined: fastly.LogInsightsResponse

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

undefined: fastly.GetLogInsightsInput

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / lint-latest (informational)

undefined: fastly.LogInsightsResponse

Check failure on line 255 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / lint-latest (informational)

undefined: fastly.GetLogInsightsInput

GetGeneratedVCL(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error)

Expand Down Expand Up @@ -442,7 +444,7 @@
}

// Ensure that fastly.Client satisfies Interface.
var _ Interface = (*fastly.Client)(nil)

Check failure on line 447 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Lint (pinned golangci-lint version)

cannot use (*fastly.Client)(nil) (value of type *fastly.Client) as Interface value in variable declaration: *fastly.Client does not implement Interface (missing method GetLogInsights) (typecheck)

Check failure on line 447 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / Test (ubuntu-latest)

cannot use (*fastly.Client)(nil) (value of type *fastly.Client) as Interface value in variable declaration: *fastly.Client does not implement Interface (missing method GetLogInsights)

Check failure on line 447 in pkg/api/interface.go

View workflow job for this annotation

GitHub Actions / lint-latest (informational)

cannot use (*fastly.Client)(nil) (value of type *fastly.Client) as Interface value in variable declaration: *fastly.Client does not implement Interface (missing method GetLogInsights) (typecheck)

// Ensure that fastly.RTSClient satisfies RealtimeStatsInterface.
var _ RealtimeStatsInterface = (*fastly.RTSClient)(nil)
6 changes: 6 additions & 0 deletions pkg/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1326,6 +1330,8 @@ func Define( // nolint:revive // function-length
kvstoreentryList,
logtailCmdRoot,
serviceloggingDebugCmd,
serviceloggingInsightsCmd,
serviceloggingLogExplorerCmd,
serviceloggingAzureblobCmdRoot,
serviceloggingAzureblobCreate,
serviceloggingAzureblobDelete,
Expand Down
3 changes: 1 addition & 2 deletions pkg/commands/service/logging/doc.go
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions pkg/commands/service/logging/insights/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Package insights contains the command for retrieving statistics from sampled logs.
package insights
249 changes: 249 additions & 0 deletions pkg/commands/service/logging/insights/insights_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading