Files
coder/cli/logs.go
T
Cian Johnston 353ebd9664 feat: add link for viewing raw build logs in workspace and template build jobs (#21727)
* Adds support for parameter `format=text` in the following API routes:
  * `/api/v2/workspaceagents/:id/logs`
  * `/api/v2/workspacebuilds/:id/logs`
  * `/api/v2/templateversions/:id/logs` 
  * `/api/v2/templateversions/:id/dry-run/:id/logs` 

* Adds links to view raw logs on the following pages:
  * Workspace build page
  * Template editor page
  * Template version page

* Refactors existing log formatting in `cli/logs.go` to live in `codersdk`.

🤖 Generated with Claude Opus 4.5, reviewed by me.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-03 09:45:23 +00:00

237 lines
6.0 KiB
Go

package cli
import (
"context"
"fmt"
"slices"
"strconv"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
func (r *RootCmd) logs() *serpent.Command {
var (
buildNumberArg int64
followArg bool
)
cmd := &serpent.Command{
Use: "logs <workspace>",
Short: "View logs for a workspace",
Long: "View logs for a workspace",
Middleware: serpent.Chain(
serpent.RequireNArgs(1),
),
Options: serpent.OptionSet{
{
Name: "Build Number",
Flag: "build-number",
FlagShorthand: "n",
Description: "Only show logs for a specific build number. Defaults to 0, which maps to the most recent build (build numbers start at 1). Negative values are treated as offsets—for example, -1 refers to the previous build.",
Value: serpent.Int64Of(&buildNumberArg),
Default: "0",
},
{
Name: "Follow",
Flag: "follow",
FlagShorthand: "f",
Description: "Follow logs as they are emitted.",
Value: serpent.BoolOf(&followArg),
Default: "false",
},
},
Handler: func(inv *serpent.Invocation) error {
ctx := inv.Context()
client, err := r.InitClient(inv)
if err != nil {
return err
}
ws, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("failed to get workspace: %w", err)
}
bld := ws.LatestBuild
buildNumber := buildNumberArg
// User supplied a negative build number, treat it as an offset from the latest build
if buildNumber < 0 {
buildNumber = int64(ws.LatestBuild.BuildNumber) + buildNumberArg
if buildNumber < 1 {
return xerrors.Errorf("invalid build number offset: %d latest build number: %d", buildNumberArg, ws.LatestBuild.BuildNumber)
}
}
// Fetch specific build if requested
if buildNumber > 0 {
wb, err := client.WorkspaceBuildByUsernameAndWorkspaceNameAndBuildNumber(ctx, ws.OwnerName, ws.Name, strconv.FormatInt(buildNumber, 10))
if err != nil {
return xerrors.Errorf("failed to get build %d: %w", buildNumberArg, err)
}
bld = wb
}
cliui.Infof(inv.Stdout, "--- Logs for workspace build #%d (ID: %s Template Version: %s) ---", bld.BuildNumber, bld.ID, bld.TemplateVersionName)
logs, logsCh, err := workspaceLogs(ctx, client, bld, followArg)
if err != nil {
return err
}
for _, log := range logs {
_, _ = fmt.Fprintln(inv.Stdout, log.text)
}
if followArg {
_, _ = fmt.Fprintln(inv.Stdout, "--- Streaming logs ---")
for log := range logsCh {
_, _ = fmt.Fprintln(inv.Stdout, log.text)
}
}
return nil
},
}
return cmd
}
type logLine struct {
ts time.Time // for sorting
text string
}
// workspaceLogs fetches logs for the given workspace build. If follow is true,
// the returned channel will stream new logs as they are emitted. Otherwise,
// the channel will be closed immediately.
// nolint: revive // control flag is appropriate here
func workspaceLogs(ctx context.Context, client *codersdk.Client, wb codersdk.WorkspaceBuild, follow bool) ([]logLine, <-chan logLine, error) {
logs := make([]logLine, 0)
logsCh := make(chan logLine)
followCh := make(chan logLine)
var fetchGroup, followGroup errgroup.Group
buildLogsAfterCh := make(chan int64)
fetchGroup.Go(func() error {
var afterID int64
defer func() {
if !follow {
return
}
buildLogsAfterCh <- afterID
}()
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, 0)
if err != nil {
return xerrors.Errorf("failed to get build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
text: log.Text(),
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-buildLogsAfterCh
buildLogsC, closer, err := client.WorkspaceBuildLogsAfter(ctx, wb.ID, afterID)
if err != nil {
return xerrors.Errorf("failed to follow build logs: %w", err)
}
defer closer.Close()
for log := range buildLogsC {
followCh <- logLine{
ts: log.CreatedAt,
text: log.Text(),
}
}
return nil
})
}
for _, res := range wb.Resources {
for _, agt := range res.Agents {
logSrcNames := make(map[uuid.UUID]string)
for _, src := range agt.LogSources {
logSrcNames[src.ID] = src.DisplayName
}
agentLogsAfterCh := make(chan int64)
var afterID int64
fetchGroup.Go(func() error {
defer func() {
if !follow {
return
}
agentLogsAfterCh <- afterID
}()
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, 0, false)
if err != nil {
return xerrors.Errorf("failed to get agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
afterID = log.ID
logsCh <- logLine{
ts: log.CreatedAt,
text: log.Text(agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
if follow {
followGroup.Go(func() error {
afterID := <-agentLogsAfterCh
agentLogsCh, closer, err := client.WorkspaceAgentLogsAfter(ctx, agt.ID, afterID, true)
if err != nil {
return xerrors.Errorf("failed to follow agent logs: %w", err)
}
defer closer.Close()
for logChunk := range agentLogsCh {
for _, log := range logChunk {
followCh <- logLine{
ts: log.CreatedAt,
text: log.Text(agt.Name, logSrcNames[log.SourceID]),
}
}
}
return nil
})
}
}
}
logsDone := make(chan struct{})
go func() {
defer close(logsDone)
for log := range logsCh {
logs = append(logs, log)
}
}()
err := fetchGroup.Wait()
close(logsCh)
<-logsDone
slices.SortFunc(logs, func(a, b logLine) int {
return a.ts.Compare(b.ts)
})
if follow {
go func() {
_ = followGroup.Wait()
close(followCh)
}()
} else {
close(followCh)
}
return logs, followCh, err
}