From f5cb2e547e272986c60389954c20b57d29fd2369 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 22 Jun 2026 16:38:18 +0300 Subject: [PATCH] feat: include rotated agent logs in support bundles (#26055) Support bundles previously captured only the active coder-agent.log, losing history across agent restarts. Add an optional `after` filter to the agent's /debug/logs endpoint: without it the endpoint is unchanged (active log only, 10 MiB cap); with it the response includes the active log plus rotated coder-agent-*.log files modified after the cutoff, newest first. Support bundles request the last 24h. Closes #25395 --- agent/agent.go | 20 -- agent/agent_test.go | 88 ++++++++ agent/debuglogs.go | 203 ++++++++++++++++++ agent/debuglogs_internal_test.go | 103 +++++++++ cli/support_test.go | 56 +++++ codersdk/workspacesdk/agentconn.go | 34 ++- .../workspacesdk/agentconn_internal_test.go | 19 ++ .../agentconnmock/agentconnmock.go | 13 +- docs/support/support-bundle.md | 2 +- support/support.go | 4 +- 10 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 agent/debuglogs.go create mode 100644 agent/debuglogs_internal_test.go diff --git a/agent/agent.go b/agent/agent.go index 017f942e71..804efda255 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -2289,26 +2289,6 @@ func (a *agent) HandleHTTPDebugManifest(w http.ResponseWriter, r *http.Request) } } -func (a *agent) HandleHTTPDebugLogs(w http.ResponseWriter, r *http.Request) { - logPath := filepath.Join(a.logDir, "coder-agent.log") - f, err := os.Open(logPath) - if err != nil { - a.logger.Error(r.Context(), "open agent log file", slog.Error(err), slog.F("path", logPath)) - w.WriteHeader(http.StatusInternalServerError) - _, _ = fmt.Fprintf(w, "could not open log file: %s", err) - return - } - defer f.Close() - - // Limit to 10MiB. - w.WriteHeader(http.StatusOK) - _, err = io.Copy(w, io.LimitReader(f, 10*1024*1024)) - if err != nil && !errors.Is(err, io.EOF) { - a.logger.Error(r.Context(), "read agent log file", slog.Error(err)) - return - } -} - func (a *agent) HTTPDebug() http.Handler { r := chi.NewRouter() diff --git a/agent/agent_test.go b/agent/agent_test.go index ac50b34aa7..6f92737795 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -3659,6 +3659,15 @@ func TestAgent_DebugServer(t *testing.T) { randLogStr, err := cryptorand.String(32) require.NoError(t, err) require.NoError(t, os.WriteFile(logPath, []byte(randLogStr), 0o600)) + newRotatedLogPath := filepath.Join(logDir, "coder-agent-2026-05-17T20-00-00.000.log") + oldRotatedLogPath := filepath.Join(logDir, "coder-agent-2026-05-17T19-00-00.000.log") + require.NoError(t, os.WriteFile(newRotatedLogPath, []byte("new rotated log"), 0o600)) + require.NoError(t, os.WriteFile(oldRotatedLogPath, []byte("old rotated log"), 0o600)) + now := time.Now() + newRotatedModTime := now.Add(-time.Minute) + oldRotatedModTime := now.Add(-48 * time.Hour) + require.NoError(t, os.Chtimes(newRotatedLogPath, newRotatedModTime, newRotatedModTime)) + require.NoError(t, os.Chtimes(oldRotatedLogPath, oldRotatedModTime, oldRotatedModTime)) derpMap, _ := tailnettest.RunDERPAndSTUN(t) //nolint:dogsled conn, _, _, _, agnt := setupAgentWithSecrets(t, agentsdk.Manifest{ @@ -3806,6 +3815,85 @@ func TestAgent_DebugServer(t *testing.T) { require.NoError(t, err) require.NotEmpty(t, string(resBody)) require.Contains(t, string(resBody), randLogStr) + require.NotContains(t, string(resBody), "new rotated log") + }) + + t.Run("LogsIncludeActiveOnlyWithAfter", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + url := srv.URL + "/debug/logs?after=" + newRotatedModTime.Add(time.Minute).UTC().Format(time.RFC3339Nano) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + body := string(resBody) + require.Contains(t, body, randLogStr) + require.Contains(t, body, "coder-agent.log") + require.NotContains(t, body, "new rotated log") + require.NotContains(t, body, "old rotated log") + }) + + t.Run("LogsIncludeRotatedWithAfter", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + url := srv.URL + "/debug/logs?after=" + newRotatedModTime.Add(-time.Minute).UTC().Format(time.RFC3339Nano) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + body := string(resBody) + require.Contains(t, body, randLogStr) + require.Contains(t, body, "coder-agent.log") + require.Contains(t, body, "coder-agent-2026-05-17T20-00-00.000.log") + require.Contains(t, body, "new rotated log") + require.NotContains(t, body, "old rotated log") + }) + + t.Run("LogsIncludeRotatedWithOlderAfter", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + url := srv.URL + "/debug/logs?after=" + oldRotatedModTime.Add(-time.Minute).UTC().Format(time.RFC3339Nano) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, res.StatusCode) + defer res.Body.Close() + resBody, err := io.ReadAll(res.Body) + require.NoError(t, err) + body := string(resBody) + require.Contains(t, body, randLogStr) + require.Contains(t, body, "new rotated log") + require.Contains(t, body, "old rotated log") + require.Less(t, strings.Index(body, randLogStr), strings.Index(body, "new rotated log")) + require.Less(t, strings.Index(body, "new rotated log"), strings.Index(body, "old rotated log")) + }) + + t.Run("LogsInvalidAfter", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/debug/logs?after=nope", nil) + require.NoError(t, err) + + res, err := srv.Client().Do(req) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusBadRequest, res.StatusCode) }) } diff --git a/agent/debuglogs.go b/agent/debuglogs.go new file mode 100644 index 0000000000..8dc16d7980 --- /dev/null +++ b/agent/debuglogs.go @@ -0,0 +1,203 @@ +package agent + +import ( + "context" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "regexp" + "slices" + "strings" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" +) + +const ( + activeAgentLogName = "coder-agent.log" + debugLogsActiveMaxBytes = 10 * 1024 * 1024 + debugLogsCombinedMaxBytes = 100 * 1024 * 1024 + // debugLogsWriteTimeout gives slow links well over the server's 20s + // WriteTimeout to stream the combined logs. + debugLogsWriteTimeout = 5 * time.Minute +) + +// coderAgentRotatedLogPattern matches lumberjack's rotated filenames, e.g. +// coder-agent-2026-05-17T20-00-00.000.log. +var coderAgentRotatedLogPattern = regexp.MustCompile(`^coder-agent-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}\.log$`) + +type agentLogFile struct { + name string + size int64 + modTime time.Time +} + +func (a *agent) HandleHTTPDebugLogs(w http.ResponseWriter, r *http.Request) { + after, hasAfter, err := parseDebugLogsAfter(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Confine reads to logDir so a symlink there cannot escape it. + root, err := os.OpenRoot(a.logDir) + if err != nil { + a.logger.Error(r.Context(), "open agent log dir", slog.Error(err), slog.F("log_dir", a.logDir)) + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "could not open log dir: %s", err) + return + } + defer root.Close() + + if !hasAfter { + a.writeActiveDebugLog(w, r, root) + return + } + + // Streaming the combined logs can exceed the server's 20s WriteTimeout, + // so extend the deadline for this response. + if err := http.NewResponseController(w).SetWriteDeadline(time.Now().Add(debugLogsWriteTimeout)); err != nil { + a.logger.Warn(r.Context(), "extend debug log write deadline", slog.Error(err)) + } + + // Open the required active log before the 200 so failures return 500. + active, err := root.Open(activeAgentLogName) + if err != nil { + a.logger.Error(r.Context(), "open agent log file", slog.Error(err), slog.F("name", activeAgentLogName)) + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "could not open log file: %s", err) + return + } + activeInfo, err := active.Stat() + if err != nil { + _ = active.Close() + a.logger.Error(r.Context(), "stat agent log file", slog.Error(err), slog.F("name", activeAgentLogName)) + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "could not stat log file: %s", err) + return + } + w.WriteHeader(http.StatusOK) + remaining := int64(debugLogsCombinedMaxBytes) + // Cap the active log at its own limit so it can't consume the whole + // budget and starve the rotated logs. + n, truncated, err := writeAgentLogSection(w, active, activeAgentLogName, activeInfo.Size(), activeInfo.ModTime(), "", min(remaining, debugLogsActiveMaxBytes)) + remaining -= n + _ = active.Close() + if err != nil { + a.logger.Error(r.Context(), "read agent log file", slog.Error(err), slog.F("name", activeAgentLogName)) + return + } + + // Then rotated logs after the cutoff, newest first. + rotated, err := rotatedAgentLogFiles(r.Context(), a.logger, root, after) + if err != nil { + a.logger.Error(r.Context(), "find rotated agent log files", slog.Error(err), slog.F("log_dir", a.logDir)) + return + } + for _, file := range rotated { + if remaining <= 0 { + truncated = true + break + } + f, err := root.Open(file.name) + if err != nil { + a.logger.Warn(r.Context(), "open rotated agent log file", slog.Error(err), slog.F("name", file.name)) + continue + } + var fileTruncated bool + n, fileTruncated, err = writeAgentLogSection(w, f, file.name, file.size, file.modTime, "\n", remaining) + remaining -= n + truncated = truncated || fileTruncated + _ = f.Close() + if err != nil { + a.logger.Error(r.Context(), "read rotated agent log file", slog.Error(err), slog.F("name", file.name)) + return + } + } + if truncated { + a.logger.Warn(r.Context(), "agent debug logs response truncated", slog.F("limit_bytes", debugLogsCombinedMaxBytes)) + } +} + +func parseDebugLogsAfter(r *http.Request) (after time.Time, hasAfter bool, err error) { + raw := strings.TrimSpace(r.URL.Query().Get("after")) + if raw == "" { + return time.Time{}, false, nil + } + after, err = time.Parse(time.RFC3339Nano, raw) + if err != nil { + return time.Time{}, false, xerrors.Errorf("after must be an RFC3339 timestamp: %w", err) + } + return after, true, nil +} + +func (a *agent) writeActiveDebugLog(w http.ResponseWriter, r *http.Request, root *os.Root) { + f, err := root.Open(activeAgentLogName) + if err != nil { + a.logger.Error(r.Context(), "open agent log file", slog.Error(err), slog.F("name", activeAgentLogName)) + w.WriteHeader(http.StatusInternalServerError) + _, _ = fmt.Fprintf(w, "could not open log file: %s", err) + return + } + defer f.Close() + + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, io.LimitReader(f, debugLogsActiveMaxBytes)) + if err != nil { + a.logger.Error(r.Context(), "read agent log file", slog.Error(err)) + return + } +} + +// writeAgentLogSection writes a separator and header for the file, then streams +// up to budget bytes of r. It returns the bytes written and, from size (r's +// full length), whether r was truncated. +func writeAgentLogSection(w io.Writer, r io.Reader, name string, size int64, modTime time.Time, separator string, budget int64) (written int64, truncated bool, err error) { + header := separator + fmt.Sprintf("=== %s (mtime %s) ===\n", name, modTime.UTC().Format(time.RFC3339Nano)) + if int64(len(header)) > budget { + return 0, size > 0, nil + } + if _, err := io.WriteString(w, header); err != nil { + return 0, false, err + } + contentBudget := budget - int64(len(header)) + n, err := io.Copy(w, io.LimitReader(r, contentBudget)) + return int64(len(header)) + n, size > contentBudget, err +} + +// rotatedAgentLogFiles returns rotated logs after the cutoff, newest first, +// excluding the active log and any non-regular files such as symlinks. +func rotatedAgentLogFiles(ctx context.Context, logger slog.Logger, root *os.Root, after time.Time) ([]agentLogFile, error) { + entries, err := fs.ReadDir(root.FS(), ".") + if err != nil { + return nil, xerrors.Errorf("read log directory: %w", err) + } + rotated := make([]agentLogFile, 0, len(entries)) + for _, entry := range entries { + base := entry.Name() + if !coderAgentRotatedLogPattern.MatchString(base) { + continue + } + info, err := entry.Info() + if err != nil { + logger.Warn(ctx, "stat rotated agent log file", slog.Error(err), slog.F("name", base)) + continue + } + if !info.Mode().IsRegular() || info.ModTime().Before(after) { + continue + } + rotated = append(rotated, agentLogFile{ + name: base, + size: info.Size(), + modTime: info.ModTime(), + }) + } + slices.SortFunc(rotated, func(a, b agentLogFile) int { + return b.modTime.Compare(a.modTime) + }) + return rotated, nil +} diff --git a/agent/debuglogs_internal_test.go b/agent/debuglogs_internal_test.go new file mode 100644 index 0000000000..5610a92684 --- /dev/null +++ b/agent/debuglogs_internal_test.go @@ -0,0 +1,103 @@ +package agent + +import ( + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" +) + +func TestHandleHTTPDebugLogsWithAfterCapsResponse(t *testing.T) { + t.Parallel() + + logDir := t.TempDir() + activePath := filepath.Join(logDir, "coder-agent.log") + f, err := os.Create(activePath) + require.NoError(t, err) + // A huge active log must not starve the rotated logs. + require.NoError(t, f.Truncate(debugLogsCombinedMaxBytes+1)) + require.NoError(t, f.Close()) + + rotatedPath := filepath.Join(logDir, "coder-agent-2026-05-17T20-00-00.000.log") + require.NoError(t, os.WriteFile(rotatedPath, []byte("rotated marker\n"), 0o600)) + rotatedModTime := time.Now().Add(-time.Minute) + require.NoError(t, os.Chtimes(rotatedPath, rotatedModTime, rotatedModTime)) + + a := &agent{ + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), + logDir: logDir, + } + req := httptest.NewRequest(http.MethodGet, "/debug/logs?after="+time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano), nil) + res := httptest.NewRecorder() + + a.HandleHTTPDebugLogs(res, req) + + require.Equal(t, http.StatusOK, res.Code) + body := res.Body.String() + // Active is capped at its own limit, so the rotated log still fits. + require.Less(t, int64(len(body)), int64(debugLogsCombinedMaxBytes)) + require.Contains(t, body, "coder-agent.log") + require.Contains(t, body, "rotated marker") +} + +func TestHandleHTTPDebugLogsWithAfterOpenFailure(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("unix sockets only") + } + + logDir, err := os.MkdirTemp("/tmp", "coder-debuglogs-") + require.NoError(t, err) + t.Cleanup(func() { + _ = os.RemoveAll(logDir) + }) + activePath := filepath.Join(logDir, "coder-agent.log") + listener, err := net.Listen("unix", activePath) + require.NoError(t, err) + t.Cleanup(func() { + _ = listener.Close() + }) + + a := &agent{ + logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug), + logDir: logDir, + } + req := httptest.NewRequest(http.MethodGet, "/debug/logs?after="+time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano), nil) + res := httptest.NewRecorder() + + a.HandleHTTPDebugLogs(res, req) + + require.Equal(t, http.StatusInternalServerError, res.Code) + require.Contains(t, res.Body.String(), "could not open log file") +} + +func TestRotatedAgentLogFilesReadsLogDirLiterally(t *testing.T) { + t.Parallel() + + root := t.TempDir() + logDir := filepath.Join(root, "logs[abc]") + require.NoError(t, os.Mkdir(logDir, 0o700)) + activePath := filepath.Join(logDir, "coder-agent.log") + rotatedPath := filepath.Join(logDir, "coder-agent-2026-05-18T00-00-00.000.log") + require.NoError(t, os.WriteFile(activePath, []byte("active log"), 0o600)) + require.NoError(t, os.WriteFile(rotatedPath, []byte("rotated log"), 0o600)) + + dirRoot, err := os.OpenRoot(logDir) + require.NoError(t, err) + t.Cleanup(func() { _ = dirRoot.Close() }) + + files, err := rotatedAgentLogFiles(t.Context(), slogtest.Make(t, nil), dirRoot, time.Now().Add(-time.Minute)) + + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, "coder-agent-2026-05-18T00-00-00.000.log", files[0].name) +} diff --git a/cli/support_test.go b/cli/support_test.go index 3edada4bfa..f1c8632b84 100644 --- a/cli/support_test.go +++ b/cli/support_test.go @@ -80,6 +80,11 @@ func TestSupportBundle(t *testing.T) { agents[0].Env["SECRET_VALUE"] = secretValue return agents }) + workspaceWithRotatedAgentLogs := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, func(agents []*proto.Agent) []*proto.Agent { + // This should not show up in the bundle output + agents[0].Env["SECRET_VALUE"] = secretValue + return agents + }) workspaceWithoutAgent := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, owner.UserID, nil) memberWorkspace := setupSupportBundleTestFixture(setupCtx, t, api.Database, owner.OrganizationID, member.ID, nil) @@ -105,6 +110,57 @@ func TestSupportBundle(t *testing.T) { assertBundleContents(t, path, true, true, []string{secretValue}) }) + t.Run("WorkspaceWithRotatedAgentLogs", func(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + logPath := filepath.Join(tempDir, "coder-agent.log") + require.NoError(t, os.WriteFile(logPath, []byte("hello from the agent"), 0o600)) + + rotatedPath := filepath.Join(tempDir, "coder-agent-2026-05-18T00-00-00.000.log") + require.NoError(t, os.WriteFile(rotatedPath, []byte("rotated log"), 0o600)) + oldRotatedPath := filepath.Join(tempDir, "coder-agent-2026-05-17T00-00-00.000.log") + require.NoError(t, os.WriteFile(oldRotatedPath, []byte("old rotated log"), 0o600)) + now := time.Now() + require.NoError(t, os.Chtimes(rotatedPath, now, now)) + oldRotatedTime := now.Add(-48 * time.Hour) + require.NoError(t, os.Chtimes(oldRotatedPath, oldRotatedTime, oldRotatedTime)) + + agt := agenttest.New(t, client.URL, workspaceWithRotatedAgentLogs.AgentToken, func(o *agent.Options) { + o.LogDir = tempDir + }) + defer agt.Close() + coderdtest.NewWorkspaceAgentWaiter(t, client, workspaceWithRotatedAgentLogs.Workspace.ID).Wait() + + d := t.TempDir() + path := filepath.Join(d, "bundle.zip") + inv, root := clitest.New(t, "support", "bundle", workspaceWithRotatedAgentLogs.Workspace.Name, "--output-file", path, "--yes") + //nolint: gocritic // requires owner privilege + clitest.SetupConfig(t, client, root) + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.NoError(t, err) + + r, err := zip.OpenReader(path) + require.NoError(t, err, "open zip file") + defer r.Close() + + found := false + for _, f := range r.File { + assertDoesNotContain(t, f, secretValue) + if f.Name != "agent/logs.txt" { + continue + } + found = true + bs := readBytesFromZip(t, f) + body := string(bs) + require.Contains(t, body, "hello from the agent") + require.Contains(t, body, "rotated log") + require.NotContains(t, body, "old rotated log") + } + require.True(t, found, "expected agent/logs.txt in bundle") + }) + t.Run("NoWorkspace", func(t *testing.T) { t.Parallel() diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index a47a19db26..2b4ab3384b 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -98,7 +98,7 @@ type AgentConn interface { CallMCPTool(ctx context.Context, req CallMCPToolRequest) (CallMCPToolResponse, error) Close() error ContextConfig(ctx context.Context) (ContextConfigResponse, error) - DebugLogs(ctx context.Context) ([]byte, error) + DebugLogs(ctx context.Context, opts ...DebugLogsOption) ([]byte, error) DebugMagicsock(ctx context.Context) ([]byte, error) DebugManifest(ctx context.Context) ([]byte, error) DialContext(ctx context.Context, network string, addr string) (net.Conn, error) @@ -443,11 +443,29 @@ func (c *agentConn) DebugManifest(ctx context.Context) ([]byte, error) { return bs, nil } -// DebugLogs returns up to the last 10MB of `/tmp/coder-agent.log` -func (c *agentConn) DebugLogs(ctx context.Context) ([]byte, error) { +// DebugLogsOption configures a DebugLogs request. +type DebugLogsOption func(*debugLogsConfig) + +type debugLogsConfig struct { + after time.Time +} + +// WithLogsAfter also returns rotated logs modified at or after t, separated by +// boundary markers (100 MiB combined cap). +func WithLogsAfter(t time.Time) DebugLogsOption { + return func(c *debugLogsConfig) { c.after = t } +} + +// DebugLogs returns up to 10 MiB of the active agent log. Pass WithLogsAfter to +// also include rotated logs. +func (c *agentConn) DebugLogs(ctx context.Context, opts ...DebugLogsOption) ([]byte, error) { + var cfg debugLogsConfig + for _, opt := range opts { + opt(&cfg) + } ctx, span := tracing.StartSpan(ctx) defer span.End() - res, err := c.apiRequest(ctx, http.MethodGet, "/debug/logs", nil) + res, err := c.apiRequest(ctx, http.MethodGet, debugLogsPath(cfg.after), nil) if err != nil { return nil, xerrors.Errorf("do request: %w", err) } @@ -462,6 +480,14 @@ func (c *agentConn) DebugLogs(ctx context.Context) ([]byte, error) { return bs, nil } +func debugLogsPath(after time.Time) string { + query := neturl.Values{} + if !after.IsZero() { + query.Set("after", after.UTC().Format(time.RFC3339Nano)) + } + return agentAPIPath("/debug/logs", query) +} + // PrometheusMetrics returns a response from the agent's prometheus metrics endpoint func (c *agentConn) PrometheusMetrics(ctx context.Context) ([]byte, error) { ctx, span := tracing.StartSpan(ctx) diff --git a/codersdk/workspacesdk/agentconn_internal_test.go b/codersdk/workspacesdk/agentconn_internal_test.go index 1721a3ff26..2432fb0989 100644 --- a/codersdk/workspacesdk/agentconn_internal_test.go +++ b/codersdk/workspacesdk/agentconn_internal_test.go @@ -3,6 +3,7 @@ package workspacesdk import ( neturl "net/url" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -48,4 +49,22 @@ func TestAgentAPIPath(t *testing.T) { require.Equal(t, "50", parsed.Query().Get("max_response_lines")) require.Equal(t, "60", parsed.Query().Get("max_response_bytes")) }) + + t.Run("debug logs zero after", func(t *testing.T) { + t.Parallel() + + got := debugLogsPath(time.Time{}) + require.Equal(t, "/debug/logs", got) + }) + + t.Run("debug logs after", func(t *testing.T) { + t.Parallel() + + after := time.Date(2026, 5, 18, 12, 34, 56, 789, time.FixedZone("test", -7*60*60)) + got := debugLogsPath(after) + parsed, err := neturl.Parse(got) + require.NoError(t, err) + require.Equal(t, "/debug/logs", parsed.Path) + require.Equal(t, after.UTC().Format(time.RFC3339Nano), parsed.Query().Get("after")) + }) } diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index 5c23246cae..7e91f2e681 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -130,18 +130,23 @@ func (mr *MockAgentConnMockRecorder) ContextConfig(ctx any) *gomock.Call { } // DebugLogs mocks base method. -func (m *MockAgentConn) DebugLogs(ctx context.Context) ([]byte, error) { +func (m *MockAgentConn) DebugLogs(ctx context.Context, opts ...workspacesdk.DebugLogsOption) ([]byte, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DebugLogs", ctx) + varargs := []any{ctx} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "DebugLogs", varargs...) ret0, _ := ret[0].([]byte) ret1, _ := ret[1].(error) return ret0, ret1 } // DebugLogs indicates an expected call of DebugLogs. -func (mr *MockAgentConnMockRecorder) DebugLogs(ctx any) *gomock.Call { +func (mr *MockAgentConnMockRecorder) DebugLogs(ctx any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugLogs", reflect.TypeOf((*MockAgentConn)(nil).DebugLogs), ctx) + varargs := append([]any{ctx}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugLogs", reflect.TypeOf((*MockAgentConn)(nil).DebugLogs), varargs...) } // DebugMagicsock mocks base method. diff --git a/docs/support/support-bundle.md b/docs/support/support-bundle.md index b28ffc6c1d..bf9c22d50d 100644 --- a/docs/support/support-bundle.md +++ b/docs/support/support-bundle.md @@ -33,7 +33,7 @@ A brief overview of all files contained in the bundle is provided below: | `agent/agent_magicsock.html` | The contents of the HTTP debug endpoint of the agent's Tailscale Wireguard connection. | | `agent/client_magicsock.html` | The contents of the HTTP debug endpoint of the client's Tailscale Wireguard connection. | | `agent/listening_ports.json` | The listening ports detected by the selected agent running in the workspace. | -| `agent/logs.txt` | The logs of the selected agent running in the workspace. | +| `agent/logs.txt` | Active agent log plus rotated agent logs modified in the last 24 hours, capped at 100 MiB. | | `agent/manifest.json` | The manifest of the selected agent with environment variables stripped. | | `agent/startup_logs.txt` | Startup logs of the workspace agent. | | `agent/prometheus.txt` | The contents of the agent's Prometheus endpoint. | diff --git a/support/support.go b/support/support.go index 3c634dd9ac..de30ee8554 100644 --- a/support/support.go +++ b/support/support.go @@ -33,6 +33,8 @@ import ( "github.com/coder/coder/v2/tailnet" ) +const supportBundleAgentLogLookback = 24 * time.Hour + // Bundle is a set of information discovered about a deployment. // Even though we do attempt to sanitize data, it may still contain // sensitive information and should thus be treated as secret. @@ -665,7 +667,7 @@ func connectedAgentInfo(ctx context.Context, client *codersdk.Client, log slog.L }) eg.Go(func() error { - logBytes, err := conn.DebugLogs(ctx) + logBytes, err := conn.DebugLogs(ctx, workspacesdk.WithLogsAfter(time.Now().Add(-supportBundleAgentLogLookback))) if err != nil { return xerrors.Errorf("fetch coder agent logs: %w", err) }