Repository: digitalocean/doctl (go)

Issue:
doctl apps logs doesn't output JSON
**What is the problem this feature would solve? Please describe.**

On our apps services, we use JSON logging (pino) and it'd be really helpful to be able to do `doctl apps logs <id> -f | pino-pretty` but I can't because the log lines include the service name and the log timestamp, not just the log message:

```
webapp 2024-05-29T20:14:14.996999111Z {"level":30,"time":1717013654993,  ....
```

**Describe the solution you'd like**

I'd like to just get what I logged, i.e., the JSON, without the `webapp 2024-05-29T20:14:14.996999111Z ` prefix.

**Additional context**

This would greatly improve my experience debugging apps on DO's App platform (currently we use papertrail, but it's not a particularly good log querier (doesn't parse the log lines correctly)


Pull request:
fix(app): #1536 fix logging format to work with pino-pretty
## Summary
This PR introduces a new --output-json flag for the RunAppsGetLogs function in the DigitalOcean CLI (doctl). When this flag is used, the logs are outputted in JSON format without the service name and timestamp prefix.

### Changes Made
- Added --output-json flag to the RunAppsGetLogs function.
- Modified the log processing logic to remove the service name and timestamp prefix when the --output-json flag is used.

Closes #1536 


Patch:
diff --git a/args.go b/args.go
index fee16266..416e3f1c 100644
--- a/args.go
+++ b/args.go
@@ -54,6 +54,8 @@ const (
 	ArgAppLogFollow = "follow"
 	// ArgAppLogTail tail logs.
 	ArgAppLogTail = "tail"
+	// ArgNoPrefix no prefix to json logs
+	ArgNoPrefix = "no-prefix"
 	// ArgAppForceRebuild forces a deployment rebuild
 	ArgAppForceRebuild = "force-rebuild"
 	// ArgAppAlertDestinations is a path to an app alert destination file.
diff --git a/commands/apps.go b/commands/apps.go
index 5db806fb..def9e921 100644
--- a/commands/apps.go
+++ b/commands/apps.go
@@ -14,6 +14,7 @@ limitations under the License.
 package commands
 
 import (
+	"bufio"
 	"bytes"
 	"encoding/json"
 	"fmt"
@@ -186,6 +187,8 @@ For more information about logs, see [How to View Logs](https://www.digitalocean
 	AddStringFlag(logs, doctl.ArgAppLogType, "", strings.ToLower(string(godo.AppLogTypeRun)), "Retrieves logs for a specific log type. Defaults to run logs.")
 	AddBoolFlag(logs, doctl.ArgAppLogFollow, "f", false, "Returns logs as they are emitted by the app.")
 	AddIntFlag(logs, doctl.ArgAppLogTail, "", -1, "Specifies the number of lines to show from the end of the log.")
+	AddBoolFlag(logs, doctl.ArgNoPrefix, "", false, "Removes the prefix from logs. Useful for JSON structured logs")
+
 	logs.Example = `The following example retrieves the build logs for the app with the ID ` + "`" + `f81d4fae-7dec-11d0-a765-00a0c91e6bf6` + "`" + ` and the component ` + "`" + `web` + "`" + `: doctl apps logs f81d4fae-7dec-11d0-a765-00a0c91e6bf6 web --type build`
 
 	listRegions := CmdBuilder(
@@ -610,6 +613,11 @@ func RunAppsGetLogs(c *CmdConfig) error {
 		return err
 	}
 
+	noPrefixFlag, err := c.Doit.GetBool(c.NS, doctl.ArgNoPrefix)
+	if err != nil {
+		return err
+	}
+
 	logs, err := c.Apps().GetLogs(appID, deploymentID, component, logType, logFollow, logTail)
 	if err != nil {
 		return err
@@ -631,6 +639,18 @@ func RunAppsGetLogs(c *CmdConfig) error {
 			}
 			r := strings.NewReader(data.Data)
 
+			if noPrefixFlag {
+				content, err := io.ReadAll(r)
+				if err != nil {
+					return nil, err
+				}
+				logParts := strings.SplitN(string(content), " ", 3)
+				if len(logParts) > 2 {
+					jsonLog := logParts[2]
+					return strings.NewReader(jsonLog), nil
+				}
+			}
+
 			return r, nil
 		}
 
@@ -654,7 +674,20 @@ func RunAppsGetLogs(c *CmdConfig) error {
 		}
 		defer resp.Body.Close()
 
-		io.Copy(c.Out, resp.Body)
+		scanner := bufio.NewScanner(resp.Body)
+		for scanner.Scan() {
+			logLine := scanner.Text()
+			if noPrefixFlag {
+				logParts := strings.SplitN(logLine, " ", 3)
+				if len(logParts) > 2 {
+					logLine = logParts[2]
+				}
+			}
+			fmt.Fprintln(c.Out, logLine)
+		}
+		if err := scanner.Err(); err != nil {
+			return err
+		}
 	} else {
 		warn("No logs found for app component")
 	}


Test patch:
diff --git a/integration/apps_test.go b/integration/apps_test.go
index 3f72d776..531df000 100644
--- a/integration/apps_test.go
+++ b/integration/apps_test.go
@@ -923,7 +923,7 @@ var _ = suite("apps/get-logs", func(t *testing.T, when spec.G, it spec.S) {
 		wsServer *httptest.Server
 		upgrader = websocket.Upgrader{}
 	)
-
+	now := time.Now().Format(time.RFC3339)
 	it.Before(func() {
 		expect = require.New(t)
 
@@ -985,7 +985,7 @@ var _ = suite("apps/get-logs", func(t *testing.T, when spec.G, it spec.S) {
 				data := struct {
 					Data string `json:"data"`
 				}{
-					Data: "fake logs\n",
+					Data: fmt.Sprintf("foo-service %v fake logs\n", now),
 				}
 				buf := new(bytes.Buffer)
 				json.NewEncoder(buf).Encode(data)
@@ -1020,6 +1020,28 @@ var _ = suite("apps/get-logs", func(t *testing.T, when spec.G, it spec.S) {
 		output, err := cmd.CombinedOutput()
 		expect.NoError(err)
 
+		logLine := fmt.Sprintf("foo-service %v fake logs", now)
+		expectedOutput := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", logLine, logLine, logLine, logLine, logLine)
+		expect.Equal(expectedOutput, strings.TrimSpace(string(output)))
+	})
+	it("removes the prefix from an app's logs", func() {
+		cmd := exec.Command(builtBinaryPath,
+			"-t", "some-magic-token",
+			"-u", server.URL,
+			"apps",
+			"logs",
+			testAppUUID,
+			"service",
+			"--deployment="+testDeploymentUUID,
+			"--type=run",
+			"--tail=1",
+			"-f",
+			"--no-prefix",
+		)
+
+		output, err := cmd.CombinedOutput()
+		expect.NoError(err)
+
 		expectedOutput := "fake logs\nfake logs\nfake logs\nfake logs\nfake logs"
 		expect.Equal(expectedOutput, strings.TrimSpace(string(output)))
 	})