Skip to content

Commit f8f42b2

Browse files
Shaun Thompsonclaude
andcommitted
history logs: print error summary at end of output
When a build fails, `history logs` now prints an error summary after all progress output, matching the error detail already shown by `history inspect`. This includes the error message, source location (e.g. the failing Dockerfile line), logs from the failed vertex, and a stack trace hint. Particularly useful for cache key computation failures where no vertex logs appear during the main log replay. Extracts loadBuildErrorOutput and printErrorDetails as shared helpers so both commands use the same formatting logic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ce05817 commit f8f42b2

File tree

3 files changed

+241
-52
lines changed

3 files changed

+241
-52
lines changed

commands/history/inspect.go

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -297,46 +297,16 @@ workers0:
297297
}
298298

299299
if rec.Error != nil || rec.ExternalError != nil {
300-
out.Error = &errorOutput{}
301300
if rec.Error != nil {
302301
if codes.Code(rec.Error.Code) == codes.Canceled {
303302
out.Status = statusCanceled
304303
} else {
305304
out.Status = statusError
306305
}
307-
out.Error.Code = int(codes.Code(rec.Error.Code))
308-
out.Error.Message = rec.Error.Message
309306
}
310-
if rec.ExternalError != nil {
311-
dt, err := content.ReadBlob(ctx, store, ociDesc(rec.ExternalError))
312-
if err != nil {
313-
return errors.Wrapf(err, "failed to read external error %s", rec.ExternalError.Digest)
314-
}
315-
var st spb.Status
316-
if err := proto.Unmarshal(dt, &st); err != nil {
317-
return errors.Wrapf(err, "failed to unmarshal external error %s", rec.ExternalError.Digest)
318-
}
319-
retErr := grpcerrors.FromGRPC(status.ErrorProto(&st))
320-
var errsources bytes.Buffer
321-
for _, s := range errdefs.Sources(retErr) {
322-
s.Print(&errsources)
323-
errsources.WriteString("\n")
324-
}
325-
out.Error.Sources = errsources.Bytes()
326-
var ve *errdefs.VertexError
327-
if errors.As(retErr, &ve) {
328-
dgst, err := digest.Parse(ve.Digest)
329-
if err != nil {
330-
return errors.Wrapf(err, "failed to parse vertex digest %s", ve.Digest)
331-
}
332-
name, logs, err := loadVertexLogs(ctx, c, rec.Ref, dgst, 16)
333-
if err != nil {
334-
return errors.Wrapf(err, "failed to load vertex logs %s", dgst)
335-
}
336-
out.Error.Name = name
337-
out.Error.Logs = logs
338-
}
339-
out.Error.Stack = fmt.Appendf(nil, "%+v", stack.Formatter(retErr))
307+
var loadErr error
308+
if out.Error, loadErr = loadBuildErrorOutput(ctx, c, rec); loadErr != nil {
309+
return loadErr
340310
}
341311
}
342312

@@ -616,24 +586,7 @@ workers0:
616586
}
617587

618588
if out.Error != nil {
619-
if out.Error.Sources != nil {
620-
fmt.Fprint(dockerCli.Out(), string(out.Error.Sources))
621-
}
622-
if len(out.Error.Logs) > 0 {
623-
fmt.Fprintln(dockerCli.Out(), "Logs:")
624-
fmt.Fprintf(dockerCli.Out(), "> => %s:\n", out.Error.Name)
625-
for _, l := range out.Error.Logs {
626-
fmt.Fprintln(dockerCli.Out(), "> "+l)
627-
}
628-
fmt.Fprintln(dockerCli.Out())
629-
}
630-
if len(out.Error.Stack) > 0 {
631-
if debug.IsEnabled() {
632-
fmt.Fprintf(dockerCli.Out(), "\n%s\n", out.Error.Stack)
633-
} else {
634-
fmt.Fprintf(dockerCli.Out(), "Enable --debug to see stack traces for error\n")
635-
}
636-
}
589+
printErrorDetails(dockerCli.Out(), out.Error)
637590
}
638591

639592
fmt.Fprintf(dockerCli.Out(), "Print build logs: docker buildx history logs %s\n", rec.Ref)
@@ -671,6 +624,78 @@ func inspectCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {
671624
return cmd
672625
}
673626

627+
// printErrorDetails prints the sources, logs, and stack trace from an error output.
628+
func printErrorDetails(w io.Writer, errOut *errorOutput) {
629+
if len(errOut.Sources) > 0 {
630+
fmt.Fprint(w, string(errOut.Sources))
631+
}
632+
if len(errOut.Logs) > 0 {
633+
fmt.Fprintln(w, "Logs:")
634+
fmt.Fprintf(w, "> => %s:\n", errOut.Name)
635+
for _, l := range errOut.Logs {
636+
fmt.Fprintln(w, "> "+l)
637+
}
638+
fmt.Fprintln(w)
639+
}
640+
if len(errOut.Stack) > 0 {
641+
if debug.IsEnabled() {
642+
fmt.Fprintf(w, "\n%s\n", errOut.Stack)
643+
} else {
644+
fmt.Fprintf(w, "Enable --debug to see stack traces for error\n")
645+
}
646+
}
647+
}
648+
649+
// loadBuildErrorOutput builds an errorOutput from a history record's error fields.
650+
// It returns nil if the record has no error.
651+
func loadBuildErrorOutput(ctx context.Context, c *client.Client, rec *historyRecord) (*errorOutput, error) {
652+
if rec.Error == nil && rec.ExternalError == nil {
653+
return nil, nil
654+
}
655+
656+
out := &errorOutput{}
657+
658+
if rec.Error != nil {
659+
out.Code = int(codes.Code(rec.Error.Code))
660+
out.Message = rec.Error.Message
661+
}
662+
663+
if rec.ExternalError != nil {
664+
store := proxy.NewContentStore(c.ContentClient())
665+
dt, err := content.ReadBlob(ctx, store, ociDesc(rec.ExternalError))
666+
if err != nil {
667+
return nil, errors.Wrapf(err, "failed to read external error %s", rec.ExternalError.Digest)
668+
}
669+
var st spb.Status
670+
if err := proto.Unmarshal(dt, &st); err != nil {
671+
return nil, errors.Wrapf(err, "failed to unmarshal external error %s", rec.ExternalError.Digest)
672+
}
673+
retErr := grpcerrors.FromGRPC(status.ErrorProto(&st))
674+
var errsources bytes.Buffer
675+
for _, s := range errdefs.Sources(retErr) {
676+
s.Print(&errsources)
677+
errsources.WriteString("\n")
678+
}
679+
out.Sources = errsources.Bytes()
680+
var ve *errdefs.VertexError
681+
if errors.As(retErr, &ve) {
682+
dgst, err := digest.Parse(ve.Digest)
683+
if err != nil {
684+
return nil, errors.Wrapf(err, "failed to parse vertex digest %s", ve.Digest)
685+
}
686+
name, logs, err := loadVertexLogs(ctx, c, rec.Ref, dgst, 16)
687+
if err != nil {
688+
return nil, errors.Wrapf(err, "failed to load vertex logs %s", dgst)
689+
}
690+
out.Name = name
691+
out.Logs = logs
692+
}
693+
out.Stack = fmt.Appendf(nil, "%+v", stack.Formatter(retErr))
694+
}
695+
696+
return out, nil
697+
}
698+
674699
func loadVertexLogs(ctx context.Context, c *client.Client, ref string, dgst digest.Digest, limit int) (string, []string, error) {
675700
st, err := c.ControlClient().Status(ctx, &controlapi.StatusRequest{
676701
Ref: ref,

commands/history/logs.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package history
22

33
import (
44
"context"
5+
"fmt"
56
"io"
67
"os"
78

@@ -13,6 +14,7 @@ import (
1314
"github.com/moby/buildkit/util/progress/progressui"
1415
"github.com/pkg/errors"
1516
"github.com/spf13/cobra"
17+
"google.golang.org/grpc/codes"
1618
)
1719

1820
type logsOptions struct {
@@ -79,7 +81,29 @@ loop0:
7981
}
8082
}
8183

82-
return printer.Wait()
84+
printerErr := printer.Wait()
85+
86+
errOut, err := loadBuildErrorOutput(ctx, c, rec)
87+
if err != nil {
88+
return err
89+
}
90+
printLogsError(dockerCli.Err(), errOut)
91+
92+
return printerErr
93+
}
94+
95+
// printLogsError prints a summary of a build error at the end of log output.
96+
func printLogsError(w io.Writer, errOut *errorOutput) {
97+
if errOut == nil {
98+
return
99+
}
100+
fmt.Fprintln(w)
101+
if codes.Code(errOut.Code) == codes.Canceled {
102+
fmt.Fprintf(w, "Build canceled\n")
103+
} else if errOut.Message != "" {
104+
fmt.Fprintf(w, "Error: %s %s\n", codes.Code(errOut.Code).String(), errOut.Message)
105+
}
106+
printErrorDetails(w, errOut)
83107
}
84108

85109
func logsCmd(dockerCli command.Cli, rootOpts RootOptions) *cobra.Command {

commands/history/logs_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package history
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"testing"
7+
8+
controlapi "github.com/moby/buildkit/api/services/control"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
spb "google.golang.org/genproto/googleapis/rpc/status"
12+
"google.golang.org/grpc/codes"
13+
)
14+
15+
func TestLoadBuildErrorOutput_NoError(t *testing.T) {
16+
rec := &historyRecord{
17+
BuildHistoryRecord: &controlapi.BuildHistoryRecord{},
18+
}
19+
out, err := loadBuildErrorOutput(context.Background(), nil, rec)
20+
require.NoError(t, err)
21+
assert.Nil(t, out)
22+
}
23+
24+
func TestLoadBuildErrorOutput_GRPCError(t *testing.T) {
25+
rec := &historyRecord{
26+
BuildHistoryRecord: &controlapi.BuildHistoryRecord{
27+
Error: &spb.Status{
28+
Code: int32(codes.Internal),
29+
Message: "failed to solve: process did not complete successfully",
30+
},
31+
},
32+
}
33+
out, err := loadBuildErrorOutput(context.Background(), nil, rec)
34+
require.NoError(t, err)
35+
require.NotNil(t, out)
36+
assert.Equal(t, int(codes.Internal), out.Code)
37+
assert.Equal(t, "failed to solve: process did not complete successfully", out.Message)
38+
assert.Nil(t, out.Sources)
39+
assert.Empty(t, out.Logs)
40+
}
41+
42+
func TestLoadBuildErrorOutput_CanceledError(t *testing.T) {
43+
rec := &historyRecord{
44+
BuildHistoryRecord: &controlapi.BuildHistoryRecord{
45+
Error: &spb.Status{
46+
Code: int32(codes.Canceled),
47+
Message: "context canceled",
48+
},
49+
},
50+
}
51+
out, err := loadBuildErrorOutput(context.Background(), nil, rec)
52+
require.NoError(t, err)
53+
require.NotNil(t, out)
54+
assert.Equal(t, int(codes.Canceled), out.Code)
55+
}
56+
57+
func TestPrintLogsError_Nil(t *testing.T) {
58+
var buf bytes.Buffer
59+
printLogsError(&buf, nil)
60+
assert.Empty(t, buf.String())
61+
}
62+
63+
func TestPrintLogsError_GRPCError(t *testing.T) {
64+
var buf bytes.Buffer
65+
printLogsError(&buf, &errorOutput{
66+
Code: int(codes.Internal),
67+
Message: "failed to solve: dockerfile parse error",
68+
})
69+
out := buf.String()
70+
assert.Contains(t, out, "Error: Internal failed to solve: dockerfile parse error")
71+
}
72+
73+
func TestPrintLogsError_CanceledError(t *testing.T) {
74+
var buf bytes.Buffer
75+
printLogsError(&buf, &errorOutput{
76+
Code: int(codes.Canceled),
77+
})
78+
out := buf.String()
79+
assert.Contains(t, out, "Build canceled")
80+
assert.NotContains(t, out, "Error:")
81+
}
82+
83+
func TestPrintLogsError_WithSources(t *testing.T) {
84+
var buf bytes.Buffer
85+
printLogsError(&buf, &errorOutput{
86+
Code: int(codes.Internal),
87+
Message: "failed to solve",
88+
Sources: []byte("Dockerfile:5\n > 5: RUN exit 1\n"),
89+
})
90+
out := buf.String()
91+
assert.Contains(t, out, "Error: Internal failed to solve")
92+
assert.Contains(t, out, "Dockerfile:5")
93+
assert.Contains(t, out, "RUN exit 1")
94+
}
95+
96+
func TestPrintLogsError_WithLogs(t *testing.T) {
97+
var buf bytes.Buffer
98+
printLogsError(&buf, &errorOutput{
99+
Code: int(codes.Internal),
100+
Message: "failed to solve",
101+
Name: "RUN echo hello",
102+
Logs: []string{"hello", "world"},
103+
})
104+
out := buf.String()
105+
assert.Contains(t, out, "Logs:")
106+
assert.Contains(t, out, "> => RUN echo hello:")
107+
assert.Contains(t, out, "> hello")
108+
assert.Contains(t, out, "> world")
109+
}
110+
111+
func TestPrintErrorDetails_SourcesLogsStack(t *testing.T) {
112+
var buf bytes.Buffer
113+
printErrorDetails(&buf, &errorOutput{
114+
Sources: []byte("Dockerfile:5\n > 5: RUN exit 1\n"),
115+
Name: "RUN exit 1",
116+
Logs: []string{"step output"},
117+
Stack: []byte("goroutine 1 [running]:\n..."),
118+
})
119+
out := buf.String()
120+
assert.Contains(t, out, "Dockerfile:5")
121+
assert.Contains(t, out, "Logs:")
122+
assert.Contains(t, out, "> step output")
123+
assert.Contains(t, out, "Enable --debug to see stack traces for error")
124+
// header line is not printed by printErrorDetails
125+
assert.NotContains(t, out, "Error:")
126+
assert.NotContains(t, out, "Build canceled")
127+
}
128+
129+
func TestPrintLogsError_StackWithoutDebug(t *testing.T) {
130+
var buf bytes.Buffer
131+
printLogsError(&buf, &errorOutput{
132+
Code: int(codes.Internal),
133+
Message: "failed to solve",
134+
Stack: []byte("goroutine 1 [running]:\n..."),
135+
})
136+
out := buf.String()
137+
// debug is not enabled in tests, so we should see the hint
138+
assert.Contains(t, out, "Enable --debug to see stack traces for error")
139+
assert.NotContains(t, out, "goroutine 1")
140+
}

0 commit comments

Comments
 (0)