Skip to content

Commit 15e6744

Browse files
authored
Support JSON output formatting for apps logs command (#4087)
## Changes - Support JSON output formatting for `apps logs` command ## Why To be consistent with the global flags we have. ## Tests Unit tests are in place ## Screenshot <img width="1164" height="252" alt="image" src="https://github.com/user-attachments/assets/57df83dc-0e9d-4841-a20f-98a13d1422b3" /> ## Caveat While we disable coloring on CLI side for JSON output, you might still see sth like: <img width="590" height="499" alt="image" src="https://github.com/user-attachments/assets/6dc9848c-14c0-49ad-9a25-fe3a186d15e9" /> Those ANSI codes come from server and I decided not to alter them and print as they are. In theory we could add a regex pattern and strip them out but I believe it's better to print exactly what comes from the server.
1 parent 7285314 commit 15e6744

File tree

5 files changed

+133
-29
lines changed

5 files changed

+133
-29
lines changed

cmd/workspace/apps/logs.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/databricks/cli/libs/cmdctx"
2020
"github.com/databricks/cli/libs/cmdgroup"
2121
"github.com/databricks/cli/libs/cmdio"
22+
"github.com/databricks/cli/libs/flags"
2223
"github.com/databricks/cli/libs/log"
2324
"github.com/databricks/databricks-sdk-go/config"
2425
"github.com/databricks/databricks-sdk-go/service/apps"
@@ -142,7 +143,9 @@ via --source APP|SYSTEM. Use --output-file to mirror the stream to a local file
142143
defer file.Close()
143144
writer = io.MultiWriter(writer, file)
144145
}
145-
colorizeLogs := outputPath == "" && cmdio.IsTTY(cmd.OutOrStdout())
146+
147+
outputFormat := root.OutputType(cmd)
148+
colorizeLogs := outputPath == "" && outputFormat == flags.OutputText && cmdio.IsTTY(cmd.OutOrStdout())
146149

147150
sourceMap, err := buildSourceFilter(sourceFilters)
148151
if err != nil {
@@ -165,6 +168,7 @@ via --source APP|SYSTEM. Use --output-file to mirror the stream to a local file
165168
Writer: writer,
166169
UserAgent: "databricks-cli apps logs",
167170
Colorize: colorizeLogs,
171+
OutputFormat: outputFormat,
168172
})
169173
},
170174
}

libs/apps/logstream/formatter.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77
"time"
88

9+
"github.com/databricks/cli/libs/flags"
910
"github.com/fatih/color"
1011
)
1112

@@ -27,16 +28,25 @@ func parseLogEntry(raw []byte) (*wsEntry, error) {
2728

2829
// logFormatter formats log entries for output.
2930
type logFormatter struct {
30-
colorize bool
31+
colorize bool
32+
outputFormat flags.Output
3133
}
3234

3335
// newLogFormatter creates a new log formatter.
34-
func newLogFormatter(colorize bool) *logFormatter {
35-
return &logFormatter{colorize: colorize}
36+
func newLogFormatter(colorize bool, outputFormat flags.Output) *logFormatter {
37+
return &logFormatter{colorize: colorize, outputFormat: outputFormat}
3638
}
3739

3840
// FormatEntry formats a structured log entry for output.
3941
func (f *logFormatter) FormatEntry(entry *wsEntry) string {
42+
if f.outputFormat == flags.OutputJSON {
43+
return f.formatEntryJSON(entry)
44+
}
45+
return f.formatEntryText(entry)
46+
}
47+
48+
// formatEntryText formats a structured log entry as human-readable text.
49+
func (f *logFormatter) formatEntryText(entry *wsEntry) string {
4050
timestamp := formatTimestamp(entry.Timestamp)
4151
source := strings.ToUpper(entry.Source)
4252
message := strings.TrimRight(entry.Message, "\r\n")
@@ -49,6 +59,20 @@ func (f *logFormatter) FormatEntry(entry *wsEntry) string {
4959
return fmt.Sprintf("%s [%s] %s", timestamp, source, message)
5060
}
5161

62+
// formatEntryJSON formats a structured log entry as JSON (NDJSON line).
63+
func (f *logFormatter) formatEntryJSON(entry *wsEntry) string {
64+
normalized := wsEntry{
65+
Source: strings.ToUpper(entry.Source),
66+
Timestamp: entry.Timestamp,
67+
Message: strings.TrimRight(entry.Message, "\r\n"),
68+
}
69+
data, err := json.Marshal(normalized)
70+
if err != nil {
71+
return f.formatEntryText(entry)
72+
}
73+
return string(data)
74+
}
75+
5276
// FormatPlain formats a plain text message by trimming trailing newlines.
5377
func (f *logFormatter) FormatPlain(raw []byte) string {
5478
return strings.TrimRight(string(raw), "\r\n")
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package logstream
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/databricks/cli/libs/flags"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestFormatter_FormatEntry(t *testing.T) {
13+
entry := &wsEntry{Source: "app", Timestamp: 1705315800.0, Message: "hello world\n"}
14+
15+
t.Run("json output", func(t *testing.T) {
16+
jsonFormatter := newLogFormatter(false, flags.OutputJSON)
17+
output := jsonFormatter.FormatEntry(entry)
18+
19+
var parsed wsEntry
20+
require.NoError(t, json.Unmarshal([]byte(output), &parsed))
21+
22+
assert.Equal(t, "APP", parsed.Source)
23+
assert.Greater(t, parsed.Timestamp, 0.0)
24+
assert.Equal(t, "hello world", parsed.Message)
25+
assert.NotContains(t, output, "\x1b[")
26+
})
27+
28+
t.Run("text output", func(t *testing.T) {
29+
textFormatter := newLogFormatter(false, flags.OutputText)
30+
output := textFormatter.FormatEntry(entry)
31+
32+
assert.Contains(t, output, "[APP]")
33+
assert.Contains(t, output, "hello world")
34+
assert.Contains(t, output, "2024-01-15")
35+
assert.NotContains(t, output, "\x1b[")
36+
})
37+
}

libs/apps/logstream/streamer.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"sync"
1313
"time"
1414

15+
"github.com/databricks/cli/libs/flags"
1516
"github.com/gorilla/websocket"
1617
)
1718

@@ -50,6 +51,7 @@ type Config struct {
5051
Writer io.Writer
5152
UserAgent string
5253
Colorize bool
54+
OutputFormat flags.Output
5355
}
5456

5557
// Run connects to the log stream described by cfg and copies frames to the writer.
@@ -72,7 +74,7 @@ func Run(ctx context.Context, cfg Config) error {
7274
prefetch: cfg.Prefetch,
7375
writer: cfg.Writer,
7476
userAgent: cfg.UserAgent,
75-
formatter: newLogFormatter(cfg.Colorize),
77+
formatter: newLogFormatter(cfg.Colorize, cfg.OutputFormat),
7678
}
7779
if streamer.userAgent == "" {
7880
streamer.userAgent = defaultUserAgent
@@ -267,7 +269,7 @@ func (s *logStreamer) formatMessage(message []byte) string {
267269
source := strings.ToUpper(entry.Source)
268270
if len(s.sources) > 0 {
269271
if _, ok := s.sources[source]; !ok {
270-
return "" // Filtered out
272+
return ""
271273
}
272274
}
273275
return s.formatter.FormatEntry(entry)

libs/apps/logstream/streamer_test.go

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/databricks/cli/libs/flags"
1718
"github.com/fatih/color"
1819
"github.com/gorilla/websocket"
1920
"github.com/stretchr/testify/assert"
@@ -44,7 +45,7 @@ func TestLogStreamerTailBufferFlushes(t *testing.T) {
4445
follow: false,
4546
prefetch: 25 * time.Millisecond,
4647
writer: buf,
47-
formatter: newLogFormatter(false),
48+
formatter: newLogFormatter(false, flags.OutputText),
4849
}
4950

5051
require.NoError(t, streamer.Run(context.Background()))
@@ -77,7 +78,7 @@ func TestLogStreamerTailFlushErrorPropagates(t *testing.T) {
7778
follow: false,
7879
prefetch: 0,
7980
writer: &failWriter{err: writerErr},
80-
formatter: newLogFormatter(false),
81+
formatter: newLogFormatter(false, flags.OutputText),
8182
}
8283

8384
err := streamer.Run(context.Background())
@@ -102,7 +103,7 @@ func TestLogStreamerTrimsCRLFInStructuredEntries(t *testing.T) {
102103
url: toWebSocketURL(server.URL),
103104
token: "token",
104105
writer: buf,
105-
formatter: newLogFormatter(false),
106+
formatter: newLogFormatter(false, flags.OutputText),
106107
}
107108

108109
require.NoError(t, streamer.Run(context.Background()))
@@ -125,7 +126,7 @@ func TestLogStreamerDialErrorIncludesResponseBody(t *testing.T) {
125126
url: toWebSocketURL(server.URL),
126127
token: "test",
127128
writer: &bytes.Buffer{},
128-
formatter: newLogFormatter(false),
129+
formatter: newLogFormatter(false, flags.OutputText),
129130
}
130131

131132
err := streamer.Run(context.Background())
@@ -152,7 +153,7 @@ func TestLogStreamerRetriesOnDialFailure(t *testing.T) {
152153
follow: true,
153154
prefetch: 0,
154155
writer: buf,
155-
formatter: newLogFormatter(false),
156+
formatter: newLogFormatter(false, flags.OutputText),
156157
}
157158

158159
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
@@ -182,7 +183,7 @@ func TestLogStreamerSendsSearchTerm(t *testing.T) {
182183
token: "test",
183184
search: "ERROR",
184185
writer: buf,
185-
formatter: newLogFormatter(false),
186+
formatter: newLogFormatter(false, flags.OutputText),
186187
}
187188

188189
require.NoError(t, streamer.Run(context.Background()))
@@ -196,7 +197,7 @@ func TestLogStreamerFiltersSources(t *testing.T) {
196197
defer conn.Close()
197198
_, _, _ = conn.ReadMessage()
198199
require.NoError(t, sendEntry(conn, 1, "app"))
199-
require.NoError(t, conn.WriteMessage(websocket.TextMessage, mustJSON(wsEntry{Source: "SYSTEM", Timestamp: 2, Message: "sys"})))
200+
require.NoError(t, conn.WriteMessage(websocket.TextMessage, mustJSON(t, wsEntry{Source: "SYSTEM", Timestamp: 2, Message: "sys"})))
200201
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second))
201202
})
202203
defer server.Close()
@@ -210,7 +211,7 @@ func TestLogStreamerFiltersSources(t *testing.T) {
210211
token: "test",
211212
sources: sources,
212213
writer: buf,
213-
formatter: newLogFormatter(false),
214+
formatter: newLogFormatter(false, flags.OutputText),
214215
}
215216

216217
require.NoError(t, streamer.Run(context.Background()))
@@ -226,22 +227,58 @@ func TestFormatLogEntryColorizesWhenEnabled(t *testing.T) {
226227

227228
entry := &wsEntry{Source: "app", Timestamp: 1, Message: "hello\n"}
228229

229-
colorFormatter := newLogFormatter(true)
230+
colorFormatter := newLogFormatter(true, flags.OutputText)
230231
colored := colorFormatter.FormatEntry(entry)
231232
assert.Contains(t, colored, "\x1b[")
232233
assert.Contains(t, colored, fmt.Sprintf("[%s]", color.HiBlueString("APP")))
233234

234-
plainFormatter := newLogFormatter(false)
235+
plainFormatter := newLogFormatter(false, flags.OutputText)
235236
plain := plainFormatter.FormatEntry(entry)
236237
assert.NotContains(t, plain, "\x1b[")
237238
assert.Contains(t, plain, "[APP]")
238239
}
239240

240-
func mustJSON(entry wsEntry) []byte {
241-
raw, err := json.Marshal(entry)
242-
if err != nil {
243-
panic(err)
241+
func TestLogStreamerOutputsNDJSON(t *testing.T) {
242+
t.Parallel()
243+
244+
server := newTestLogServer(t, func(id int, conn *websocket.Conn) {
245+
defer conn.Close()
246+
_, _, _ = conn.ReadMessage()
247+
require.NoError(t, sendEntry(conn, 1.0, "first message"))
248+
require.NoError(t, sendEntry(conn, 2.0, "second message"))
249+
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second))
250+
})
251+
defer server.Close()
252+
253+
buf := &bytes.Buffer{}
254+
streamer := &logStreamer{
255+
dialer: &websocket.Dialer{},
256+
url: toWebSocketURL(server.URL),
257+
token: "token",
258+
writer: buf,
259+
formatter: newLogFormatter(false, flags.OutputJSON),
244260
}
261+
262+
require.NoError(t, streamer.Run(context.Background()))
263+
264+
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
265+
require.Len(t, lines, 2, "expected two NDJSON lines")
266+
267+
var entry1 wsEntry
268+
require.NoError(t, json.Unmarshal([]byte(lines[0]), &entry1))
269+
assert.Equal(t, "APP", entry1.Source)
270+
assert.Equal(t, "first message", entry1.Message)
271+
272+
var entry2 wsEntry
273+
require.NoError(t, json.Unmarshal([]byte(lines[1]), &entry2))
274+
assert.Equal(t, "APP", entry2.Source)
275+
assert.Equal(t, "second message", entry2.Message)
276+
}
277+
278+
func mustJSON(t *testing.T, entry wsEntry) []byte {
279+
raw, err := json.Marshal(entry)
280+
require.NoError(t, err)
281+
245282
return raw
246283
}
247284

@@ -267,7 +304,7 @@ func TestTailWithoutPrefetchRespectsTailSize(t *testing.T) {
267304
tail: 2,
268305
prefetch: 0,
269306
writer: buf,
270-
formatter: newLogFormatter(false),
307+
formatter: newLogFormatter(false, flags.OutputText),
271308
}
272309

273310
require.NoError(t, streamer.Run(context.Background()))
@@ -291,7 +328,7 @@ func TestCloseErrorPropagatesWhenAbnormal(t *testing.T) {
291328
dialer: &websocket.Dialer{},
292329
url: toWebSocketURL(server.URL),
293330
token: "token",
294-
formatter: newLogFormatter(false),
331+
formatter: newLogFormatter(false, flags.OutputText),
295332
}
296333

297334
err := streamer.Run(context.Background())
@@ -381,7 +418,7 @@ func TestLogStreamerTailFlushesWithoutFollow(t *testing.T) {
381418
follow: false,
382419
prefetch: 50 * time.Millisecond,
383420
writer: writer,
384-
formatter: newLogFormatter(false),
421+
formatter: newLogFormatter(false, flags.OutputText),
385422
}
386423

387424
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
@@ -422,7 +459,7 @@ func TestLogStreamerFollowTailWithoutPrefetchEmitsRequestedLines(t *testing.T) {
422459
follow: true,
423460
prefetch: 0,
424461
writer: writer,
425-
formatter: newLogFormatter(false),
462+
formatter: newLogFormatter(false, flags.OutputText),
426463
}
427464

428465
ctx, cancel := context.WithCancel(context.Background())
@@ -480,7 +517,7 @@ func TestLogStreamerFollowTailDoesNotReplayAfterReconnect(t *testing.T) {
480517
follow: true,
481518
prefetch: 0,
482519
writer: writer,
483-
formatter: newLogFormatter(false),
520+
formatter: newLogFormatter(false, flags.OutputText),
484521
}
485522

486523
ctx, cancel := context.WithCancel(context.Background())
@@ -561,7 +598,7 @@ func TestLogStreamerRefreshesTokenAfterAuthClose(t *testing.T) {
561598
tokenProvider: tokenProvider,
562599
follow: true,
563600
writer: buf,
564-
formatter: newLogFormatter(false),
601+
formatter: newLogFormatter(false, flags.OutputText),
565602
}
566603

567604
ctx, cancel := context.WithCancel(context.Background())
@@ -600,7 +637,7 @@ func TestLogStreamerEmitsPlainTextFrames(t *testing.T) {
600637
url: toWebSocketURL(server.URL),
601638
token: "token",
602639
writer: buf,
603-
formatter: newLogFormatter(false),
640+
formatter: newLogFormatter(false, flags.OutputText),
604641
}
605642

606643
require.NoError(t, streamer.Run(context.Background()))
@@ -625,7 +662,7 @@ func TestLogStreamerTimeoutStopsQuietFollowStream(t *testing.T) {
625662
url: toWebSocketURL(server.URL),
626663
token: "token",
627664
follow: true,
628-
formatter: newLogFormatter(false),
665+
formatter: newLogFormatter(false, flags.OutputText),
629666
}
630667

631668
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
@@ -718,7 +755,7 @@ func TestAppStatusCheckerStopsFollowing(t *testing.T) {
718755
follow: true,
719756
writer: buf,
720757
appStatusChecker: appStatusChecker,
721-
formatter: newLogFormatter(false),
758+
formatter: newLogFormatter(false, flags.OutputText),
722759
}
723760

724761
err := streamer.Run(context.Background())

0 commit comments

Comments
 (0)