mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: tag chat-originating agent logs with chat_id (#25019)
Workspace-agent logs emitted while serving chatd-driven requests were not correlated with the originating chat, making agent logs hard to attribute to the corresponding/originating chat. This adds agent-side chat context middleware that parses `Coder-Chat-Id` once, enriches agent access logs and structured handler/background logs, and adds a chatd bridge log when chat headers are attached to an agent connection. Closes CODAGT-324
This commit is contained in:
+17
-13
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentchat"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -85,6 +86,7 @@ func (a *API) Routes() http.Handler {
|
||||
|
||||
func (a *API) handleDesktopVNC(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
logger := a.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
// Start the desktop session (idempotent).
|
||||
_, err := a.desktop.Start(ctx)
|
||||
@@ -112,7 +114,7 @@ func (a *API) handleDesktopVNC(rw http.ResponseWriter, r *http.Request) {
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error(ctx, "failed to accept websocket", slog.Error(err))
|
||||
logger.Error(ctx, "failed to accept websocket", slog.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -128,6 +130,7 @@ func (a *API) handleDesktopVNC(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
logger := a.logger.With(agentchat.Fields(ctx)...)
|
||||
handlerStart := a.clock.Now()
|
||||
|
||||
// Update last desktop action timestamp for idle recording monitor.
|
||||
@@ -136,7 +139,7 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
// Ensure the desktop is running and grab native dimensions.
|
||||
cfg, err := a.desktop.Start(ctx)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: desktop.Start failed",
|
||||
logger.Warn(ctx, "handleAction: desktop.Start failed",
|
||||
slog.Error(err),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
@@ -156,7 +159,7 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
a.logger.Info(ctx, "handleAction: started",
|
||||
logger.Info(ctx, "handleAction: started",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
)
|
||||
@@ -272,7 +275,7 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
x, y = scaleXY(x, y)
|
||||
stepStart := a.clock.Now()
|
||||
if err := a.desktop.Click(ctx, x, y, MouseButtonLeft); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: Click failed",
|
||||
logger.Warn(ctx, "handleAction: Click failed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step", "click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
@@ -285,7 +288,7 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
a.logger.Debug(ctx, "handleAction: Click completed",
|
||||
logger.Debug(ctx, "handleAction: Click completed",
|
||||
slog.F("action", "left_click"),
|
||||
slog.F("step_ms", time.Since(stepStart).Milliseconds()),
|
||||
slog.F("elapsed_ms", a.clock.Since(handlerStart).Milliseconds()),
|
||||
@@ -473,7 +476,7 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
case <-ctx.Done():
|
||||
// Context canceled; release the key immediately.
|
||||
if err := a.desktop.KeyUp(ctx, *action.Text); err != nil {
|
||||
a.logger.Warn(ctx, "handleAction: KeyUp after context cancel", slog.Error(err))
|
||||
logger.Warn(ctx, "handleAction: KeyUp after context cancel", slog.Error(err))
|
||||
}
|
||||
return
|
||||
case <-timer.C:
|
||||
@@ -513,14 +516,14 @@ func (a *API) handleAction(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
elapsedMs := a.clock.Since(handlerStart).Milliseconds()
|
||||
if ctx.Err() != nil {
|
||||
a.logger.Error(ctx, "handleAction: context canceled before writing response",
|
||||
logger.Error(ctx, "handleAction: context canceled before writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
slog.Error(ctx.Err()),
|
||||
)
|
||||
return
|
||||
}
|
||||
a.logger.Info(ctx, "handleAction: writing response",
|
||||
logger.Info(ctx, "handleAction: writing response",
|
||||
slog.F("action", action.Action),
|
||||
slog.F("elapsed_ms", elapsedMs),
|
||||
)
|
||||
@@ -609,6 +612,7 @@ func (a *API) handleRecordingStart(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
logger := a.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
recordingID, ok := a.decodeRecordingRequest(rw, r)
|
||||
if !ok {
|
||||
@@ -661,7 +665,7 @@ func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
|
||||
}()
|
||||
|
||||
if artifact.Size > workspacesdk.MaxRecordingSize {
|
||||
a.logger.Warn(ctx, "recording file exceeds maximum size",
|
||||
logger.Warn(ctx, "recording file exceeds maximum size",
|
||||
slog.F("recording_id", recordingID),
|
||||
slog.F("size", artifact.Size),
|
||||
slog.F("max_size", workspacesdk.MaxRecordingSize),
|
||||
@@ -677,7 +681,7 @@ func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
|
||||
// rejecting it here avoids streaming a large thumbnail over
|
||||
// the wire for nothing.
|
||||
if artifact.ThumbnailReader != nil && artifact.ThumbnailSize > workspacesdk.MaxThumbnailSize {
|
||||
a.logger.Warn(ctx, "thumbnail file exceeds maximum size, omitting",
|
||||
logger.Warn(ctx, "thumbnail file exceeds maximum size, omitting",
|
||||
slog.F("recording_id", recordingID),
|
||||
slog.F("size", artifact.ThumbnailSize),
|
||||
slog.F("max_size", workspacesdk.MaxThumbnailSize),
|
||||
@@ -701,13 +705,13 @@ func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
|
||||
"Content-Type": {"video/mp4"},
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "failed to create video multipart part",
|
||||
logger.Warn(ctx, "failed to create video multipart part",
|
||||
slog.F("recording_id", recordingID),
|
||||
slog.Error(err))
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(videoPart, artifact.Reader); err != nil {
|
||||
a.logger.Warn(ctx, "failed to write video multipart part",
|
||||
logger.Warn(ctx, "failed to write video multipart part",
|
||||
slog.F("recording_id", recordingID),
|
||||
slog.Error(err))
|
||||
return
|
||||
@@ -719,7 +723,7 @@ func (a *API) handleRecordingStop(rw http.ResponseWriter, r *http.Request) {
|
||||
"Content-Type": {"image/jpeg"},
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "failed to create thumbnail multipart part",
|
||||
logger.Warn(ctx, "failed to create thumbnail multipart part",
|
||||
slog.F("recording_id", recordingID),
|
||||
slog.Error(err))
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentchat"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
@@ -52,6 +53,7 @@ func (api *API) Routes() http.Handler {
|
||||
// independent of config changes.
|
||||
func (api *API) handleListTools(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
logger := api.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
// Check config freshness and reload if changed.
|
||||
var reloaded bool
|
||||
@@ -61,11 +63,11 @@ func (api *API) handleListTools(rw http.ResponseWriter, r *http.Request) {
|
||||
// Categorize the error for operator debugging.
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
api.logger.Warn(ctx, "mcp reload canceled by caller", slog.Error(err))
|
||||
logger.Warn(ctx, "mcp reload canceled by caller", slog.Error(err))
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
api.logger.Warn(ctx, "mcp reload timed out", slog.Error(err))
|
||||
logger.Warn(ctx, "mcp reload timed out", slog.Error(err))
|
||||
default:
|
||||
api.logger.Warn(ctx, "mcp reload failed", slog.Error(err))
|
||||
logger.Warn(ctx, "mcp reload failed", slog.Error(err))
|
||||
}
|
||||
// Fall through to return whatever tools we have.
|
||||
} else {
|
||||
@@ -78,7 +80,7 @@ func (api *API) handleListTools(rw http.ResponseWriter, r *http.Request) {
|
||||
// refreshes tools as part of the reload.
|
||||
if r.URL.Query().Get("refresh") == "true" && !reloaded {
|
||||
if err := api.manager.RefreshTools(ctx); err != nil {
|
||||
api.logger.Warn(ctx, "failed to refresh MCP tools", slog.Error(err))
|
||||
logger.Warn(ctx, "failed to refresh MCP tools", slog.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
tailscalesingleflight "tailscale.com/util/singleflight"
|
||||
|
||||
"cdr.dev/slog/v3"
|
||||
"github.com/coder/coder/v2/agent/agentchat"
|
||||
"github.com/coder/coder/v2/agent/agentexec"
|
||||
"github.com/coder/coder/v2/agent/usershell"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
@@ -248,7 +249,8 @@ func (m *Manager) doReload(ctx context.Context, mcpConfigFiles []string) error {
|
||||
// Refresh tools outside the lock to avoid blocking
|
||||
// concurrent reads during network I/O.
|
||||
if err := m.RefreshTools(ctx); err != nil {
|
||||
m.logger.Warn(ctx, "failed to refresh MCP tools after connect", slog.Error(err))
|
||||
logger := m.logger.With(agentchat.Fields(ctx)...)
|
||||
logger.Warn(ctx, "failed to refresh MCP tools after connect", slog.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -257,6 +259,8 @@ func (m *Manager) doReload(ctx context.Context, mcpConfigFiles []string) error {
|
||||
// list of server configs. Missing files are silently skipped;
|
||||
// parse errors are logged and skipped.
|
||||
func (m *Manager) parseAndDedup(ctx context.Context, mcpConfigFiles []string) ([]ServerConfig, map[string]fileSnapshot) {
|
||||
logger := m.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
// Stat before reading so the snapshot is conservatively old.
|
||||
// If a file changes between stat and read, the snapshot
|
||||
// records the old mtime, SnapshotChanged detects a mismatch
|
||||
@@ -272,7 +276,7 @@ func (m *Manager) parseAndDedup(ctx context.Context, mcpConfigFiles []string) ([
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
m.logger.Warn(ctx, "failed to parse MCP config",
|
||||
logger.Warn(ctx, "failed to parse MCP config",
|
||||
slog.F("path", configPath),
|
||||
slog.Error(err),
|
||||
)
|
||||
@@ -334,6 +338,8 @@ func (m *Manager) classifyServers(wanted map[string]ServerConfig) (*serverDiff,
|
||||
// connectAll runs connectServer in parallel for the given configs.
|
||||
// Failed connects are logged and skipped.
|
||||
func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []connectedServer {
|
||||
logger := m.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
connected []connectedServer
|
||||
@@ -343,7 +349,7 @@ func (m *Manager) connectAll(ctx context.Context, toConnect []ServerConfig) []co
|
||||
eg.Go(func() error {
|
||||
c, err := m.connectServer(ctx, cfg)
|
||||
if err != nil {
|
||||
m.logger.Warn(ctx, "skipping MCP server",
|
||||
logger.Warn(ctx, "skipping MCP server",
|
||||
slog.F("server", cfg.Name),
|
||||
slog.F("transport", cfg.Transport),
|
||||
slog.Error(err),
|
||||
@@ -481,6 +487,8 @@ func (m *Manager) CallTool(ctx context.Context, req workspacesdk.CallMCPToolRequ
|
||||
// existing cached tools for servers that failed, so a single
|
||||
// dead server doesn't block updates from healthy ones.
|
||||
func (m *Manager) RefreshTools(ctx context.Context) error {
|
||||
logger := m.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
// Snapshot servers under read lock.
|
||||
m.mu.RLock()
|
||||
servers := make(map[string]*serverEntry, len(m.servers))
|
||||
@@ -508,7 +516,7 @@ func (m *Manager) RefreshTools(ctx context.Context) error {
|
||||
result, err := entry.client.ListTools(listCtx, mcp.ListToolsRequest{})
|
||||
cancel()
|
||||
if err != nil {
|
||||
m.logger.Warn(ctx, "failed to list tools from MCP server",
|
||||
logger.Warn(ctx, "failed to list tools from MCP server",
|
||||
slog.F("server", name),
|
||||
slog.Error(err),
|
||||
)
|
||||
@@ -670,12 +678,14 @@ func (m *Manager) createTransport(ctx context.Context, cfg ServerConfig) (transp
|
||||
// updateEnv callback, then merges explicit overrides from the
|
||||
// server config on top.
|
||||
func (m *Manager) buildEnv(ctx context.Context, explicit map[string]string) []string {
|
||||
logger := m.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
env := usershell.SystemEnvInfo{}.Environ()
|
||||
if m.updateEnv != nil {
|
||||
var err error
|
||||
env, err = m.updateEnv(env)
|
||||
if err != nil {
|
||||
m.logger.Warn(ctx, "failed to enrich MCP server environment",
|
||||
logger.Warn(ctx, "failed to enrich MCP server environment",
|
||||
slog.Error(err),
|
||||
)
|
||||
env = usershell.SystemEnvInfo{}.Environ()
|
||||
|
||||
Reference in New Issue
Block a user