mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00: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:
+51
-35
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"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/wsjson"
|
||||
@@ -40,6 +41,25 @@ func (a *API) Routes() http.Handler {
|
||||
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
var watchChatID uuid.UUID
|
||||
var hasWatchChatID bool
|
||||
if chatIDStr := r.URL.Query().Get("chat_id"); chatIDStr != "" {
|
||||
if parsedChatID, parseErr := uuid.Parse(chatIDStr); parseErr == nil {
|
||||
watchChatID = parsedChatID
|
||||
hasWatchChatID = true
|
||||
|
||||
// Reuse header-derived ancestors only when the query chat
|
||||
// matches the header chat. Otherwise the ancestors belong
|
||||
// to a different chat and would be misleading in logs.
|
||||
var ancestors []uuid.UUID
|
||||
if chatContext, ok := agentchat.FromContext(ctx); ok && chatContext.ID == watchChatID {
|
||||
ancestors = chatContext.AncestorIDs
|
||||
}
|
||||
ctx = agentchat.WithContext(ctx, watchChatID, ancestors)
|
||||
}
|
||||
}
|
||||
logger := a.logger.With(agentchat.Fields(ctx)...)
|
||||
|
||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||
CompressionMode: websocket.CompressionNoContextTakeover,
|
||||
})
|
||||
@@ -58,14 +78,14 @@ func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
||||
stream := wsjson.NewStream[
|
||||
codersdk.WorkspaceAgentGitClientMessage,
|
||||
codersdk.WorkspaceAgentGitServerMessage,
|
||||
](conn, websocket.MessageText, websocket.MessageText, a.logger)
|
||||
](conn, websocket.MessageText, websocket.MessageText, logger)
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
go httpapi.HeartbeatClose(ctx, a.logger, cancel, conn)
|
||||
go httpapi.HeartbeatClose(ctx, logger, cancel, conn)
|
||||
|
||||
handler := NewHandler(a.logger, a.opts...)
|
||||
handler := NewHandler(logger, a.opts...)
|
||||
|
||||
// Scan returns nil only when no roots are subscribed; once any
|
||||
// root lands it returns either a delta or a heartbeat message.
|
||||
@@ -75,46 +95,42 @@ func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := stream.Send(*msg); err != nil {
|
||||
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
||||
logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// If a chat_id query parameter is provided and the PathStore is
|
||||
// available, subscribe to path updates for this chat.
|
||||
chatIDStr := r.URL.Query().Get("chat_id")
|
||||
if chatIDStr != "" && a.pathStore != nil {
|
||||
chatID, parseErr := uuid.Parse(chatIDStr)
|
||||
if parseErr == nil {
|
||||
// Subscribe to future path updates BEFORE reading
|
||||
// existing paths. This ordering guarantees no
|
||||
// notification from AddPaths is lost: any call that
|
||||
// lands before Subscribe is picked up by GetPaths
|
||||
// below, and any call after Subscribe delivers a
|
||||
// notification on the channel.
|
||||
notifyCh, unsubscribe := a.pathStore.Subscribe(chatID)
|
||||
defer unsubscribe()
|
||||
if hasWatchChatID && a.pathStore != nil {
|
||||
// Subscribe to future path updates BEFORE reading
|
||||
// existing paths. This ordering guarantees no
|
||||
// notification from AddPaths is lost: any call that
|
||||
// lands before Subscribe is picked up by GetPaths
|
||||
// below, and any call after Subscribe delivers a
|
||||
// notification on the channel.
|
||||
notifyCh, unsubscribe := a.pathStore.Subscribe(watchChatID)
|
||||
defer unsubscribe()
|
||||
|
||||
// Load any paths that are already tracked for this chat.
|
||||
existingPaths := a.pathStore.GetPaths(chatID)
|
||||
if len(existingPaths) > 0 {
|
||||
handler.Subscribe(existingPaths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-notifyCh:
|
||||
paths := a.pathStore.GetPaths(chatID)
|
||||
handler.Subscribe(paths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Load any paths that are already tracked for this chat.
|
||||
existingPaths := a.pathStore.GetPaths(watchChatID)
|
||||
if len(existingPaths) > 0 {
|
||||
handler.Subscribe(existingPaths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-notifyCh:
|
||||
paths := a.pathStore.GetPaths(watchChatID)
|
||||
handler.Subscribe(paths)
|
||||
handler.RequestScan()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the main run loop in a goroutine.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package agentgit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
// ExtractChatContext reads chat identity headers from the request.
|
||||
// Returns zero values if headers are absent (non-chat request).
|
||||
func ExtractChatContext(r *http.Request) (chatID uuid.UUID, ancestorIDs []uuid.UUID, ok bool) {
|
||||
raw := r.Header.Get(workspacesdk.CoderChatIDHeader)
|
||||
if raw == "" {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
chatID, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, false
|
||||
}
|
||||
rawAncestors := r.Header.Get(workspacesdk.CoderAncestorChatIDsHeader)
|
||||
if rawAncestors != "" {
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(rawAncestors), &ids); err == nil {
|
||||
for _, s := range ids {
|
||||
if id, err := uuid.Parse(s); err == nil {
|
||||
ancestorIDs = append(ancestorIDs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return chatID, ancestorIDs, true
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package agentgit_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentgit"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
func TestExtractChatContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
|
||||
ancestor1 := uuid.MustParse("11111111-2222-3333-4444-555555555555")
|
||||
ancestor2 := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chatID string // empty means header not set
|
||||
setChatID bool // whether to set the chat ID header at all
|
||||
ancestors string // empty means header not set
|
||||
setAncestors bool // whether to set the ancestor header at all
|
||||
wantChatID uuid.UUID
|
||||
wantAncestorIDs []uuid.UUID
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "NoHeadersPresent",
|
||||
setChatID: false,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_NoAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_ValidAncestors",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "MalformedChatID",
|
||||
chatID: "not-a-uuid",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_MalformedAncestorJSON",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: `{this is not json}`,
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Only valid UUIDs in the array are returned; invalid
|
||||
// entries are silently skipped.
|
||||
name: "ValidChatID_PartialValidAncestorUUIDs",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: mustMarshalJSON(t, []string{
|
||||
ancestor1.String(),
|
||||
"bad-uuid",
|
||||
ancestor2.String(),
|
||||
}),
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: []uuid.UUID{ancestor1, ancestor2},
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
// Header is explicitly set to an empty string, which
|
||||
// Header.Get returns as "".
|
||||
name: "EmptyChatIDHeader",
|
||||
chatID: "",
|
||||
setChatID: true,
|
||||
setAncestors: false,
|
||||
wantChatID: uuid.Nil,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "ValidChatID_EmptyAncestorHeader",
|
||||
chatID: validID.String(),
|
||||
setChatID: true,
|
||||
ancestors: "",
|
||||
setAncestors: true,
|
||||
wantChatID: validID,
|
||||
wantAncestorIDs: nil,
|
||||
wantOK: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
if tt.setChatID {
|
||||
r.Header.Set(workspacesdk.CoderChatIDHeader, tt.chatID)
|
||||
}
|
||||
if tt.setAncestors {
|
||||
r.Header.Set(workspacesdk.CoderAncestorChatIDsHeader, tt.ancestors)
|
||||
}
|
||||
|
||||
chatID, ancestorIDs, ok := agentgit.ExtractChatContext(r)
|
||||
|
||||
require.Equal(t, tt.wantOK, ok, "ok mismatch")
|
||||
require.Equal(t, tt.wantChatID, chatID, "chatID mismatch")
|
||||
require.Equal(t, tt.wantAncestorIDs, ancestorIDs, "ancestorIDs mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mustMarshalJSON marshals v to a JSON string, failing the test on error.
|
||||
func mustMarshalJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user