Skip to content

Commit 79c36fa

Browse files
[CDTOOL-1193] Support for Logging Endpoint Errors (#1721)
### Change summary This PR adds support for the `service logging debug` commands, which streams logging endpoint errors. All Submissions: * [x] Have you followed the guidelines in our Contributing document? * [x] Have you checked to ensure there aren't other open [Pull Requests](https://github.com/fastly/cli/pulls) for the same update/change? <!-- You can erase any parts of this template not applicable to your Pull Request. --> ### New Feature Submissions: * [x] Does your submission pass tests? ``` make test TEST_ARGS="-run TestParseLoggingError ./pkg/commands/service/logging/debug" ok github.com/fastly/cli/pkg/commands/service/logging/debug 1.017s ``` ### Changes to Core Features: * [x] Have you written new tests for your core changes, as applicable? * [x] Have you successfully run tests with your changes locally? ### Example Output: ``` INFO: Streaming logging endpoint errors for service <SID> 14:55:10 | Broken Log | Get "https://my-broken.logging.org/.well-known/fastly/logging/challenge": lookup my-broken.logging.org. on 127.0.0.1:53: no such host 14:55:21 | Broken Log | Get "https://my-broken.logging.org/.well-known/fastly/logging/challenge": lookup my-broken.logging.org. on 127.0.0.1:53: no such host 14:55:24 | Broken Log | Get "https://my-broken.logging.org/.well-known/fastly/logging/challenge": lookup my-broken.logging.org. on 127.0.0.1:53: no such host ``` --- _Note: Claude Code was leveraged to build this feature_ --------- Co-authored-by: Anthony Gomez <anthony.gomez@fastly.com>
1 parent 97fe70c commit 79c36fa

7 files changed

Lines changed: 301 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
### Enhancements:
1212

1313
- feat(auth): add `auth revoke` subcommand for revoking API tokens via `--current`, `--name`, `--token-value`, `--id`, or `--file` (bulk) [#1717](https://github.com/fastly/cli/pull/1717)
14+
- feat(service/logging/debug): add support for logging endpoint error streaming via the `service logging debug` subcommand [#1721](https://github.com/fastly/cli/pull/1721)
1415
- feat(stats): accept `--json` / `-j` as an alias for `--format=json` on all stats and help subcommands, matching the flag style used by the rest of the CLI [#1719](https://github.com/fastly/cli/pull/1719)
1516

1617
### Dependencies:

pkg/api/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ type Interface interface {
250250
GetOriginMetricsForServiceJSON(context.Context, *fastly.GetOriginMetricsInput, any) error
251251

252252
CreateManagedLogging(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error)
253+
GetLoggingEndpointErrors(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error)
253254

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

pkg/commands/commands.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ import (
121121
serviceloggingbigquery "github.com/fastly/cli/pkg/commands/service/logging/bigquery"
122122
serviceloggingcloudfiles "github.com/fastly/cli/pkg/commands/service/logging/cloudfiles"
123123
serviceloggingdatadog "github.com/fastly/cli/pkg/commands/service/logging/datadog"
124+
serviceloggingdebug "github.com/fastly/cli/pkg/commands/service/logging/debug"
124125
serviceloggingdigitalocean "github.com/fastly/cli/pkg/commands/service/logging/digitalocean"
125126
serviceloggingelasticsearch "github.com/fastly/cli/pkg/commands/service/logging/elasticsearch"
126127
serviceloggingftp "github.com/fastly/cli/pkg/commands/service/logging/ftp"
@@ -550,6 +551,7 @@ func Define( // nolint:revive // function-length
550551
servicevclSnippetList := servicevclsnippet.NewListCommand(servicevclSnippetCmdRoot.CmdClause, data)
551552
servicevclSnippetUpdate := servicevclsnippet.NewUpdateCommand(servicevclSnippetCmdRoot.CmdClause, data)
552553
serviceloggingCmdRoot := servicelogging.NewRootCommand(serviceCmdRoot.CmdClause, data)
554+
serviceloggingDebugCmd := serviceloggingdebug.NewDebugCommand(serviceloggingCmdRoot.CmdClause, data)
553555
serviceloggingAzureblobCmdRoot := serviceloggingazureblob.NewRootCommand(serviceloggingCmdRoot.CmdClause, data)
554556
serviceloggingAzureblobCreate := serviceloggingazureblob.NewCreateCommand(serviceloggingAzureblobCmdRoot.CmdClause, data)
555557
serviceloggingAzureblobDelete := serviceloggingazureblob.NewDeleteCommand(serviceloggingAzureblobCmdRoot.CmdClause, data)
@@ -1172,6 +1174,7 @@ func Define( // nolint:revive // function-length
11721174
kvstoreentryDescribe,
11731175
kvstoreentryList,
11741176
logtailCmdRoot,
1177+
serviceloggingDebugCmd,
11751178
serviceloggingAzureblobCmdRoot,
11761179
serviceloggingAzureblobCreate,
11771180
serviceloggingAzureblobDelete,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package debug
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/google/go-cmp/cmp"
8+
9+
"github.com/fastly/go-fastly/v14/fastly"
10+
)
11+
12+
// TestParseLoggingError validates we're correctly decoding individual logging error JSON.
13+
func TestParseLoggingError(t *testing.T) {
14+
data := []byte(`{"sequence_number":1,"error_time_us":1601645172164,"stream":"logging_error","message":"Failed to send log","endpoint":"my-s3-endpoint","details":"connection refused"}`)
15+
16+
var got fastly.LoggingEndpointError
17+
err := json.Unmarshal(data, &got)
18+
if err != nil {
19+
t.Fatalf("error parsing response data: %v", err)
20+
}
21+
22+
want := fastly.LoggingEndpointError{
23+
SequenceNumber: 1,
24+
Timestamp: 1601645172164,
25+
Stream: "logging_error",
26+
Message: "Failed to send log",
27+
Endpoint: "my-s3-endpoint",
28+
Details: "connection refused",
29+
}
30+
31+
if diff := cmp.Diff(want, got); diff != "" {
32+
t.Errorf("JSON unmarshal mismatch (-want +got):\n%s", diff)
33+
}
34+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// Package debug contains the command to stream live logging endpoint errors,
2+
// providing visibility into logging pipeline issues for troubleshooting and resolution.
3+
package debug
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
package debug
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/signal"
10+
"strconv"
11+
"strings"
12+
"syscall"
13+
"time"
14+
15+
"github.com/fastly/go-fastly/v14/fastly"
16+
17+
"github.com/fastly/cli/pkg/argparser"
18+
"github.com/fastly/cli/pkg/global"
19+
"github.com/fastly/cli/pkg/text"
20+
)
21+
22+
// batch wraps errors for sending to the output loop.
23+
type batch struct {
24+
Errors []fastly.LoggingEndpointError
25+
}
26+
27+
// Command is the command for streaming logging endpoint errors.
28+
type Command struct {
29+
argparser.Base
30+
31+
serviceName argparser.OptionalServiceNameID
32+
serviceID string
33+
from uint64
34+
to uint64
35+
filter string
36+
printTimestamps bool
37+
jsonOutput bool
38+
batchCh chan batch
39+
dieCh chan struct{}
40+
doneCh chan struct{}
41+
}
42+
43+
// CommandName is the string to be used to invoke this command.
44+
const CommandName = "debug"
45+
46+
// NewDebugCommand returns a new command registered in the parent.
47+
func NewDebugCommand(parent argparser.Registerer, g *global.Data) *Command {
48+
var c Command
49+
c.Globals = g
50+
c.CmdClause = parent.Command(CommandName, "Stream live logging endpoint errors")
51+
c.RegisterFlag(argparser.StringFlagOpts{
52+
Name: argparser.FlagServiceIDName,
53+
Description: argparser.FlagServiceIDDesc,
54+
Dst: &g.Manifest.Flag.ServiceID,
55+
Short: 's',
56+
})
57+
c.RegisterFlag(argparser.StringFlagOpts{
58+
Action: c.serviceName.Set,
59+
Name: argparser.FlagServiceName,
60+
Description: argparser.FlagServiceNameDesc,
61+
Dst: &c.serviceName.Value,
62+
})
63+
c.CmdClause.Flag("from", "From time, in Unix seconds").Uint64Var(&c.from)
64+
c.CmdClause.Flag("to", "To time, in Unix seconds").Uint64Var(&c.to)
65+
c.CmdClause.Flag("filter", "Filter errors by logging endpoint name").StringVar(&c.filter)
66+
c.CmdClause.Flag("timestamps", "Print full timestamps instead of compact time").BoolVar(&c.printTimestamps)
67+
c.CmdClause.Flag("json", "Output error stream as JSON").BoolVar(&c.jsonOutput)
68+
return &c
69+
}
70+
71+
// Exec implements the command interface.
72+
func (c *Command) Exec(_ io.Reader, out io.Writer) error {
73+
serviceID, source, flag, err := argparser.ServiceID(c.serviceName, *c.Globals.Manifest, c.Globals.APIClient, c.Globals.ErrLog)
74+
if err != nil {
75+
return err
76+
}
77+
if c.Globals.Verbose() {
78+
argparser.DisplayServiceID(serviceID, flag, source, out)
79+
}
80+
81+
c.serviceID = serviceID
82+
83+
c.dieCh = make(chan struct{})
84+
c.batchCh = make(chan batch)
85+
c.doneCh = make(chan struct{})
86+
87+
text.Info(out, "Streaming logging endpoint errors for service %s\n\n", c.serviceID)
88+
89+
failure := make(chan error)
90+
sigs := make(chan os.Signal, 2)
91+
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
92+
93+
// Start the output loop.
94+
go c.outputLoop(out)
95+
96+
// Start streaming the errors.
97+
go func() {
98+
failure <- c.stream(out)
99+
}()
100+
101+
select {
102+
case asyncErr := <-failure:
103+
close(c.dieCh)
104+
return asyncErr
105+
case <-c.doneCh:
106+
return nil
107+
case <-sigs:
108+
close(c.dieCh)
109+
}
110+
111+
return nil
112+
}
113+
114+
// stream fetches error data from the API and sends it to the output loop.
115+
func (c *Command) stream(out io.Writer) error {
116+
var curWindow *uint64
117+
if c.from != 0 {
118+
curWindow = &c.from
119+
}
120+
var toWindow *uint64
121+
if c.to != 0 {
122+
toWindow = &c.to
123+
}
124+
125+
// Prepare filter slice
126+
var filter []string
127+
if c.filter != "" {
128+
filter = []string{c.filter}
129+
}
130+
131+
ctx := context.Background()
132+
133+
for {
134+
// Check if we've passed the "to" requirement.
135+
if toWindow != nil && curWindow != nil && *curWindow > *toWindow {
136+
text.Info(out, "Reached window: %v which is newer than the requested 'to': %v", *curWindow, *toWindow)
137+
close(c.doneCh)
138+
break
139+
}
140+
141+
// Use go-fastly to fetch logging endpoint errors
142+
resp, err := c.Globals.APIClient.GetLoggingEndpointErrors(ctx, &fastly.LoggingEndpointErrorsInput{
143+
ServiceID: c.serviceID,
144+
From: curWindow,
145+
To: toWindow,
146+
Filter: filter,
147+
})
148+
if err != nil {
149+
c.Globals.ErrLog.Add(err)
150+
return fmt.Errorf("unable to fetch logging endpoint errors: %w", err)
151+
}
152+
153+
// Send errors to the output loop
154+
if len(resp.Errors) > 0 {
155+
c.batchCh <- batch{Errors: resp.Errors}
156+
}
157+
158+
// Check for next link to continue streaming
159+
if resp.NextFrom != "" {
160+
// Parse the next link value (it's already the from parameter value)
161+
nextFrom, err := strconv.ParseUint(resp.NextFrom, 10, 64)
162+
if err != nil {
163+
c.Globals.ErrLog.AddWithContext(err, map[string]any{
164+
"NextFrom": resp.NextFrom,
165+
})
166+
text.Error(out, "error parsing next from")
167+
continue
168+
}
169+
curWindow = &nextFrom
170+
} else {
171+
// No next link, we're done
172+
close(c.doneCh)
173+
break
174+
}
175+
}
176+
return nil
177+
}
178+
179+
// outputLoop processes the errors out of band from the request/response loop.
180+
func (c *Command) outputLoop(out io.Writer) {
181+
for {
182+
select {
183+
case <-c.dieCh:
184+
return
185+
case batch := <-c.batchCh:
186+
c.printErrors(out, batch.Errors)
187+
}
188+
}
189+
}
190+
191+
// printErrors prints error entries.
192+
func (c *Command) printErrors(out io.Writer, errors []fastly.LoggingEndpointError) {
193+
if len(errors) == 0 {
194+
return
195+
}
196+
197+
if c.jsonOutput {
198+
// Output as JSON array
199+
encoder := json.NewEncoder(out)
200+
for _, e := range errors {
201+
if err := encoder.Encode(e); err != nil {
202+
c.Globals.ErrLog.Add(err)
203+
}
204+
}
205+
} else {
206+
// Find the longest endpoint name in this batch for dynamic width
207+
maxEndpointLen := 0
208+
for _, e := range errors {
209+
if len(e.Endpoint) > maxEndpointLen {
210+
maxEndpointLen = len(e.Endpoint)
211+
}
212+
}
213+
214+
// Human-readable format - match log-tail style
215+
for _, e := range errors {
216+
// Format timestamp
217+
// #nosec G115 -- Timestamp is in microseconds, multiplication by 1000 for nanoseconds is safe for reasonable time values
218+
timestamp := time.Unix(0, int64(e.Timestamp)*1000) // Convert microseconds to nanoseconds
219+
var timeStr string
220+
if c.printTimestamps {
221+
// Full timestamp with --timestamps flag
222+
timeStr = timestamp.UTC().Format(time.RFC3339)
223+
} else {
224+
// Compact time by default (HH:MM:SS)
225+
timeStr = timestamp.UTC().Format("15:04:05")
226+
}
227+
228+
// Extract clean error message from details JSON if present
229+
errorSummary := e.Message
230+
if e.Details != "" {
231+
var detailsJSON map[string]interface{}
232+
if err := json.Unmarshal([]byte(e.Details), &detailsJSON); err == nil {
233+
// Try to extract a cleaner error message
234+
if errorMsg, ok := detailsJSON["error"].(string); ok {
235+
// Simplify common error patterns
236+
errorMsg = strings.TrimPrefix(errorMsg, "non-temporary request err: ")
237+
errorMsg = strings.TrimPrefix(errorMsg, "temporary request err: ")
238+
errorSummary = errorMsg
239+
}
240+
}
241+
}
242+
243+
// Format: time | endpoint | message
244+
fmt.Fprintf(out, "%s | %-*s | %s\n", timeStr, maxEndpointLen, e.Endpoint, errorSummary)
245+
}
246+
}
247+
248+
// Flush output immediately
249+
if f, ok := out.(*os.File); ok {
250+
_ = f.Sync()
251+
}
252+
}

pkg/mock/api.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,8 @@ type API struct {
239239
GetOriginMetricsForServiceFn func(context.Context, *fastly.GetOriginMetricsInput) (*fastly.OriginInspector, error)
240240
GetOriginMetricsForServiceJSONFn func(context.Context, *fastly.GetOriginMetricsInput, any) error
241241

242-
CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error)
242+
CreateManagedLoggingFn func(context.Context, *fastly.CreateManagedLoggingInput) (*fastly.ManagedLogging, error)
243+
GetLoggingEndpointErrorsFn func(context.Context, *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error)
243244

244245
GetGeneratedVCLFn func(context.Context, *fastly.GetGeneratedVCLInput) (*fastly.VCL, error)
245246

@@ -1373,6 +1374,11 @@ func (m API) CreateManagedLogging(ctx context.Context, i *fastly.CreateManagedLo
13731374
return m.CreateManagedLoggingFn(ctx, i)
13741375
}
13751376

1377+
// GetLoggingEndpointErrors implements Interface.
1378+
func (m API) GetLoggingEndpointErrors(ctx context.Context, i *fastly.LoggingEndpointErrorsInput) (*fastly.LoggingEndpointErrorsResponse, error) {
1379+
return m.GetLoggingEndpointErrorsFn(ctx, i)
1380+
}
1381+
13761382
// GetGeneratedVCL implements Interface.
13771383
func (m API) GetGeneratedVCL(ctx context.Context, i *fastly.GetGeneratedVCLInput) (*fastly.VCL, error) {
13781384
return m.GetGeneratedVCLFn(ctx, i)

0 commit comments

Comments
 (0)