Skip to content

Commit 6098fef

Browse files
authored
Show logs command hint on app deployment failure (#4133)
## Changes - Show logs command hint on app deployment failure - Describe view app logs in MCP guidance ## Why Improve `apps logs` command discoverability. ## Screenshot <img width="1288" height="729" alt="Screenshot 2025-12-11 at 22 57 11" src="https://github.com/user-attachments/assets/7ac2317e-1532-4d44-9e5f-029eac69e169" /> ## Tests Unit tests
1 parent b9e27aa commit 6098fef

File tree

4 files changed

+283
-0
lines changed

4 files changed

+283
-0
lines changed

cmd/workspace/apps/errors.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package apps
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
"github.com/databricks/databricks-sdk-go/apierr"
8+
"github.com/databricks/databricks-sdk-go/retries"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
const tailLinesSuggestedValue = 100
13+
14+
// isDeploymentWaitError checks if the error is from the deployment wait phase.
15+
// These are errors wrapped by retries.Halt() during GetWithTimeout().
16+
// Excludes API client errors (4xx) which are validation errors before deployment starts.
17+
func isDeploymentWaitError(err error) bool {
18+
var retriesErr *retries.Err
19+
if !errors.As(err, &retriesErr) || !retriesErr.Halt {
20+
return false
21+
}
22+
23+
// Exclude API client errors (4xx) (e.g. app not found)
24+
var apiErr *apierr.APIError
25+
if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
26+
return false
27+
}
28+
29+
return true
30+
}
31+
32+
// wrapDeploymentError wraps the error with logs hint if it's a deployment wait error.
33+
// Returns the original error unchanged if it's not a deployment wait error.
34+
func wrapDeploymentError(cmd *cobra.Command, appName string, err error) error {
35+
if err != nil && isDeploymentWaitError(err) {
36+
return newAppDeploymentError(cmd, appName, err)
37+
}
38+
return err
39+
}
40+
41+
// AppDeploymentError wraps deployment errors with a helpful logs command suggestion.
42+
type AppDeploymentError struct {
43+
Underlying error
44+
appName string
45+
profile string
46+
}
47+
48+
func (e *AppDeploymentError) Error() string {
49+
suggestion := fmt.Sprintf("\n\nTo view app logs, run:\n databricks apps logs %s --tail-lines %d",
50+
e.appName,
51+
tailLinesSuggestedValue,
52+
)
53+
if e.profile != "" {
54+
suggestion = fmt.Sprintf("%s --profile %s", suggestion, e.profile)
55+
}
56+
return e.Underlying.Error() + suggestion
57+
}
58+
59+
func (e *AppDeploymentError) Unwrap() error {
60+
return e.Underlying
61+
}
62+
63+
// newAppDeploymentError creates an AppDeploymentError with profile info from the command.
64+
func newAppDeploymentError(cmd *cobra.Command, appName string, err error) error {
65+
profile := ""
66+
profileFlag := cmd.Flag("profile")
67+
if profileFlag != nil && profileFlag.Value.String() != "" {
68+
profile = profileFlag.Value.String()
69+
}
70+
return &AppDeploymentError{
71+
Underlying: err,
72+
appName: appName,
73+
profile: profile,
74+
}
75+
}

cmd/workspace/apps/errors_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package apps
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/databricks/databricks-sdk-go/apierr"
9+
"github.com/databricks/databricks-sdk-go/retries"
10+
"github.com/spf13/cobra"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
func TestAppDeploymentError_Error_WithoutProfile(t *testing.T) {
16+
originalErr := errors.New("deployment failed: timeout")
17+
appErr := &AppDeploymentError{
18+
Underlying: originalErr,
19+
appName: "my-app",
20+
profile: "",
21+
}
22+
23+
result := appErr.Error()
24+
25+
assert.Contains(t, result, "deployment failed: timeout")
26+
assert.Contains(t, result, "To view app logs, run:")
27+
assert.Contains(t, result, "databricks apps logs my-app --tail-lines 100")
28+
assert.NotContains(t, result, "--profile")
29+
}
30+
31+
func TestAppDeploymentError_Error_WithProfile(t *testing.T) {
32+
originalErr := errors.New("deployment failed: timeout")
33+
appErr := &AppDeploymentError{
34+
Underlying: originalErr,
35+
appName: "my-app",
36+
profile: "production",
37+
}
38+
39+
result := appErr.Error()
40+
41+
assert.Contains(t, result, "deployment failed: timeout")
42+
assert.Contains(t, result, "To view app logs, run:")
43+
assert.Contains(t, result, "databricks apps logs my-app --tail-lines 100 --profile production")
44+
}
45+
46+
func TestAppDeploymentError_Unwrap(t *testing.T) {
47+
originalErr := errors.New("original error")
48+
appErr := &AppDeploymentError{
49+
Underlying: originalErr,
50+
appName: "my-app",
51+
profile: "",
52+
}
53+
54+
unwrapped := appErr.Unwrap()
55+
56+
require.Equal(t, originalErr, unwrapped)
57+
assert.ErrorIs(t, appErr, originalErr, "errors.Is should work with wrapped error")
58+
}
59+
60+
func TestWrapDeploymentError(t *testing.T) {
61+
tests := []struct {
62+
name string
63+
err error
64+
appName string
65+
expectWrapped bool
66+
description string
67+
}{
68+
{
69+
name: "nil error",
70+
err: nil,
71+
appName: "test-app",
72+
expectWrapped: false,
73+
description: "nil error should return nil unchanged",
74+
},
75+
{
76+
name: "plain error without retries wrapper",
77+
err: errors.New("some error"),
78+
appName: "test-app",
79+
expectWrapped: false,
80+
description: "plain errors should not be wrapped",
81+
},
82+
{
83+
name: "retries.Err with Halt=false",
84+
err: retries.Continues("still in progress"),
85+
appName: "test-app",
86+
expectWrapped: false,
87+
description: "transient retries errors should not be wrapped",
88+
},
89+
{
90+
name: "retries.Err with 404 API error (not found)",
91+
err: retries.Halt(fmt.Errorf("API error: %w", &apierr.APIError{
92+
StatusCode: 404,
93+
ErrorCode: "NOT_FOUND",
94+
Message: "App with name test-app does not exist or is deleted.",
95+
})),
96+
appName: "test-app",
97+
expectWrapped: false,
98+
description: "404 not found errors should not be wrapped with logs hint",
99+
},
100+
{
101+
name: "retries.Err with 400 API error (bad request)",
102+
err: retries.Halt(fmt.Errorf("API error: %w", &apierr.APIError{
103+
StatusCode: 400,
104+
ErrorCode: "BAD_REQUEST",
105+
Message: "Invalid request parameters",
106+
})),
107+
appName: "test-app",
108+
expectWrapped: false,
109+
description: "400 bad request errors should not be wrapped with logs hint",
110+
},
111+
{
112+
name: "retries.Err with 403 API error (forbidden)",
113+
err: retries.Halt(fmt.Errorf("API error: %w", &apierr.APIError{
114+
StatusCode: 403,
115+
ErrorCode: "FORBIDDEN",
116+
Message: "Access denied",
117+
})),
118+
appName: "test-app",
119+
expectWrapped: false,
120+
description: "403 forbidden errors should not be wrapped with logs hint",
121+
},
122+
{
123+
name: "retries.Err with 500 API error (server error)",
124+
err: retries.Halt(fmt.Errorf("API error: %w", &apierr.APIError{
125+
StatusCode: 500,
126+
ErrorCode: "INTERNAL_ERROR",
127+
Message: "Internal server error",
128+
})),
129+
appName: "test-app",
130+
expectWrapped: true,
131+
description: "500 server errors during wait should be wrapped with logs hint",
132+
},
133+
{
134+
name: "retries.Err without API error (deployment failure)",
135+
err: retries.Halt(errors.New("failed to reach SUCCEEDED, got FAILED: Error building app")),
136+
appName: "test-app",
137+
expectWrapped: true,
138+
description: "deployment failures should be wrapped with logs hint",
139+
},
140+
{
141+
name: "retries.Err without API error (timeout)",
142+
err: retries.Halt(errors.New("timeout waiting for deployment")),
143+
appName: "test-app",
144+
expectWrapped: true,
145+
description: "timeout errors should be wrapped with logs hint",
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
cmd := &cobra.Command{}
152+
result := wrapDeploymentError(cmd, tt.appName, tt.err)
153+
154+
if tt.expectWrapped {
155+
var appErr *AppDeploymentError
156+
require.ErrorAs(t, result, &appErr, tt.description)
157+
assert.Equal(t, tt.appName, appErr.appName)
158+
assert.ErrorIs(t, result, tt.err, "wrapped error should unwrap to original")
159+
assert.Contains(t, result.Error(), "To view app logs, run:", "should contain logs hint")
160+
} else {
161+
assert.Equal(t, tt.err, result, tt.description)
162+
if tt.err != nil {
163+
assert.NotContains(t, result.Error(), "To view app logs", "should not contain logs hint")
164+
}
165+
}
166+
})
167+
}
168+
}

cmd/workspace/apps/overrides.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,46 @@ func listDeploymentsOverride(listDeploymentsCmd *cobra.Command, listDeploymentsR
2222
{{end}}`)
2323
}
2424

25+
func createOverride(createCmd *cobra.Command, createReq *apps.CreateAppRequest) {
26+
originalRunE := createCmd.RunE
27+
createCmd.RunE = func(cmd *cobra.Command, args []string) error {
28+
err := originalRunE(cmd, args)
29+
return wrapDeploymentError(cmd, createReq.App.Name, err)
30+
}
31+
}
32+
33+
func deployOverride(deployCmd *cobra.Command, deployReq *apps.CreateAppDeploymentRequest) {
34+
originalRunE := deployCmd.RunE
35+
deployCmd.RunE = func(cmd *cobra.Command, args []string) error {
36+
err := originalRunE(cmd, args)
37+
return wrapDeploymentError(cmd, deployReq.AppName, err)
38+
}
39+
}
40+
41+
func createUpdateOverride(createUpdateCmd *cobra.Command, createUpdateReq *apps.AsyncUpdateAppRequest) {
42+
originalRunE := createUpdateCmd.RunE
43+
createUpdateCmd.RunE = func(cmd *cobra.Command, args []string) error {
44+
err := originalRunE(cmd, args)
45+
return wrapDeploymentError(cmd, createUpdateReq.AppName, err)
46+
}
47+
}
48+
49+
func startOverride(startCmd *cobra.Command, startReq *apps.StartAppRequest) {
50+
originalRunE := startCmd.RunE
51+
startCmd.RunE = func(cmd *cobra.Command, args []string) error {
52+
err := originalRunE(cmd, args)
53+
return wrapDeploymentError(cmd, startReq.Name, err)
54+
}
55+
}
56+
2557
func init() {
2658
cmdOverrides = append(cmdOverrides, func(cmd *cobra.Command) {
2759
cmd.AddCommand(newLogsCommand())
2860
})
2961
listOverrides = append(listOverrides, listOverride)
3062
listDeploymentsOverrides = append(listDeploymentsOverrides, listDeploymentsOverride)
63+
createOverrides = append(createOverrides, createOverride)
64+
deployOverrides = append(deployOverrides, deployOverride)
65+
createUpdateOverrides = append(createUpdateOverrides, createUpdateOverride)
66+
startOverrides = append(startOverrides, startOverride)
3167
}

experimental/apps-mcp/lib/prompts/target_apps.tmpl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ This is battle-tested to catch common issues before deployment. Prefer using thi
2020
### View and Manage
2121
invoke_databricks_cli 'bundle summary'
2222

23+
### View App Logs
24+
To troubleshoot deployed apps, view their logs:
25+
invoke_databricks_cli 'apps logs <app-name> --tail-lines 100'
26+
2327
### Local Development vs Deployed Apps
2428

2529
During development:

0 commit comments

Comments
 (0)