Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/gomodel/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 5 additions & 7 deletions internal/admin/audit_projection.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,23 +124,21 @@ func looksLikeResponsesOutput(v any) bool {
}

// slimConversationEntry strips the fields the Interactions drawer never
// reads from a conversation-thread entry. The drawer builds its transcript
// from id, timestamp, request_body, response_body, and error_message;
// attempts, request revisions (each carrying a full rewritten body), and
// header maps only inflate the response — for agent traffic they roughly
// double it.
// reads from a conversation-thread entry. Request headers are retained because
// the drawer uses the safe, redacted subset to continue the same session from
// the original endpoint. Response headers, attempts, and request revisions
// only inflate the response.
func slimConversationEntry(entry *auditlog.LogEntry) {
d := entry.Data
if d == nil {
return
}
if d.Attempts == nil && d.RequestRevisions == nil && d.RequestHeaders == nil && d.ResponseHeaders == nil {
if d.Attempts == nil && d.RequestRevisions == nil && d.ResponseHeaders == nil {
return
}
slim := *d
slim.Attempts = nil
slim.RequestRevisions = nil
slim.RequestHeaders = nil
slim.ResponseHeaders = nil
entry.Data = &slim
}
7 changes: 5 additions & 2 deletions internal/admin/audit_projection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,11 @@ func TestAuditConversationSlimsEntries(t *testing.T) {
if d.ErrorMessage != "boom" {
t.Error("error_message feeds the drawer's error rendering; it must survive")
}
if d.Attempts != nil || d.RequestRevisions != nil || d.RequestHeaders != nil || d.ResponseHeaders != nil {
t.Errorf("attempts/revisions/headers must be stripped from conversation entries, got %+v", d)
if d.Attempts != nil || d.RequestRevisions != nil || d.ResponseHeaders != nil {
t.Errorf("attempts/revisions/response headers must be stripped from conversation entries, got %+v", d)
}
if d.RequestHeaders["content-type"] != "application/json" {
t.Errorf("redacted request headers are required for follow-ups, got %+v", d.RequestHeaders)
}
}

Expand Down
68 changes: 0 additions & 68 deletions internal/admin/dashboard/static/dist/assets/index-B-Rv4AUL.js

This file was deleted.

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions internal/admin/dashboard/static/dist/assets/index-DwpOmyPo.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 5 additions & 11 deletions internal/admin/handler_audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@ const maxAuditLogLimit = 100
// reports the same limit an enabled reader would.
const defaultAuditLogLimit = 25

// conversationBuildTimeout bounds the response-chain walk behind
// /admin/audit/conversation. Indexed lookups finish in milliseconds; the
// deadline exists so a degraded store (mis-planned query, lock contention,
// pool starvation) yields a partial thread instead of an endless request.
// conversationBuildTimeout bounds the store lookups behind the conversation
// endpoint. The linkage fallback returns the partial thread if it expires.
const conversationBuildTimeout = 10 * time.Second

// AuditLog handles GET /admin/audit/log
Expand Down Expand Up @@ -419,10 +417,11 @@ func (h *Handler) AuditLogDetail(c *echo.Context) error {

// AuditConversation handles GET /admin/audit/conversation
//
// @Summary Get conversation thread around an audit log entry
// @Summary Get the interaction session containing an audit log entry
// @Description Thread entries carry the request/response bodies the
// @Description transcript is built from; attempts, request revisions, and
// @Description header maps are omitted.
// @Description response headers are omitted; redacted request headers are
// @Description retained so the dashboard can continue the same session.
// @Tags admin
// @Produce json
// @Security BearerAuth
Expand Down Expand Up @@ -460,11 +459,6 @@ func (h *Handler) AuditConversation(c *echo.Context) error {
})
}

// The chain walk runs up to ~2×limit sequential store lookups; without a
// deadline one slow lookup holds the request open until a fronting proxy
// kills it. Under the deadline the builder returns the partial thread it
// collected (Truncated=true); only a timeout before the anchor loads
// surfaces as an error.
ctx, cancel := context.WithTimeout(c.Request().Context(), conversationBuildTimeout)
defer cancel()
result, err := h.auditReader.GetConversation(ctx, logID, limit)
Expand Down
10 changes: 10 additions & 0 deletions internal/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ func (m *mockAuditReader) GetLogByID(_ context.Context, _ string) (*auditlog.Log
return m.logByID, nil
}

func (m *mockAuditReader) GetInteractionParent(_ context.Context, _ string) (*auditlog.InteractionParent, error) {
if m.logByIDErr != nil {
return nil, m.logByIDErr
}
if m.logByID == nil {
return nil, nil
}
return &auditlog.InteractionParent{UserPath: m.logByID.UserPath, SessionID: m.logByID.SessionID}, nil
}

func (m *mockAuditReader) GetRequestStats(_ context.Context, params auditlog.RequestStatsParams) (*auditlog.RequestStats, error) {
m.lastStatsParams = params
if m.statsErr != nil {
Expand Down
11 changes: 6 additions & 5 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
livePublishersEnabled := false
usageEnabledForDashboard := usageResult.Logger.Config().Enabled
if adminCfg.EndpointsEnabled {
adminHandler, dashHandler, adminErr := initAdmin(
adminHandler, dashHandler, auditReader, adminErr := initAdmin(
usageReader,
usageReadStorage,
sharedStorage,
Expand Down Expand Up @@ -682,6 +682,7 @@ func New(ctx context.Context, cfg Config) (*App, error) {
} else {
serverCfg.AdminEndpointsEnabled = true
serverCfg.AdminHandler = adminHandler
serverCfg.AuditReader = auditReader
livePublishersEnabled = true
slog.Info("admin API enabled",
"api", config.JoinBasePath(appCfg.Server.BasePath, "/admin"),
Expand Down Expand Up @@ -1028,7 +1029,7 @@ func initAdmin(
usagePricingRecalculationEnabled bool,
basePath string,
uiEnabled bool,
) (*admin.Handler, *dashboard.Handler, error) {
) (*admin.Handler, *dashboard.Handler, auditlog.Reader, error) {
// Pricing recalculation writes through the same storage the reader uses.
var pricingRecalculator usage.PricingRecalculator
if usageReadStorage != nil && usagePricingRecalculationEnabled {
Expand All @@ -1048,7 +1049,7 @@ func initAdmin(
var err error
auditReader, err = auditlog.NewReader(auditStorage)
if err != nil {
return nil, nil, fmt.Errorf("failed to create audit reader: %w", err)
return nil, nil, nil, fmt.Errorf("failed to create audit reader: %w", err)
}
}

Expand Down Expand Up @@ -1093,11 +1094,11 @@ func initAdmin(
var err error
dashHandler, err = dashboard.NewWithDemoMode(basePath, runtimeConfig.DemoMode == "on")
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize dashboard: %w", err)
return nil, nil, nil, fmt.Errorf("failed to initialize dashboard: %w", err)
}
}

return adminHandler, dashHandler, nil
return adminHandler, dashHandler, auditReader, nil
}

func configGuardrailDefinitions(cfg config.GuardrailsConfig) ([]guardrails.Definition, error) {
Expand Down
124 changes: 123 additions & 1 deletion internal/auditlog/conversation_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,130 @@ import (
"errors"
"sort"
"strings"
"time"

"go.mongodb.org/mongo-driver/v2/bson"
)

type entryLookup func(ctx context.Context, id string) (*LogEntry, error)
type sessionPageLookup func(ctx context.Context, params LogQueryParams) (*LogListResult, error)

// buildSessionConversation returns a bounded portion of an audit session. It
// retains the selected anchor and favors the closest fetched entries when the
// limit excludes it. Session requests are the
// authoritative interaction thread for chat/completions and messages, whose
// payloads do not carry Responses API linkage IDs.
func buildSessionConversation(ctx context.Context, anchor *LogEntry, limit int, getPage sessionPageLookup) (*ConversationResult, error) {
limit = clampConversationLimit(limit)
if anchor == nil {
return &ConversationResult{Entries: []LogEntry{}}, nil
}
userPath := strings.TrimSpace(anchor.UserPath)
if userPath == "" {
userPath = "/"
}

entries := make([]LogEntry, 0, limit)
seen := make(map[string]struct{}, limit)
total := 0
var beforeTimestamp time.Time
var beforeID string
for len(entries) < limit {
pageSize := min(limit-len(entries), 100)
page, err := getPage(ctx, LogQueryParams{
SessionID: anchor.SessionID,
UserPath: userPath,
Limit: pageSize,
OmitAttempts: true,
ExactUserPath: true,
beforeTimestamp: beforeTimestamp,
beforeID: beforeID,
})
Comment thread
SantiagoDePolonia marked this conversation as resolved.
if err != nil {
return nil, err
}
if page == nil {
break
}
if beforeID == "" {
total = page.Total
}
for _, entry := range page.Entries {
if entry.ID != "" {
if _, exists := seen[entry.ID]; exists {
continue
}
seen[entry.ID] = struct{}{}
}
entries = append(entries, entry)
}
if len(page.Entries) == 0 {
break
}
last := page.Entries[len(page.Entries)-1]
if last.ID == "" || last.Timestamp.IsZero() ||
(last.ID == beforeID && last.Timestamp.Equal(beforeTimestamp)) {
break
}
beforeTimestamp, beforeID = last.Timestamp, last.ID
}

anchorFound := false
for i := range entries {
if entries[i].ID == anchor.ID {
anchorFound = true
break
}
}
if !anchorFound {
entries = append(entries, *anchor)
}

sort.Slice(entries, func(i, j int) bool {
if !entries[i].Timestamp.Equal(entries[j].Timestamp) {
return entries[i].Timestamp.Before(entries[j].Timestamp)
}
return entries[i].ID < entries[j].ID
})
truncated := total > len(entries)
if len(entries) > limit {
anchorIndex := 0
for i := range entries {
if entries[i].ID == anchor.ID {
anchorIndex = i
break
}
}
if anchorIndex < len(entries)/2 {
entries = entries[:limit]
} else {
entries = entries[len(entries)-limit:]
}
truncated = true
}

return &ConversationResult{
AnchorID: anchor.ID,
Entries: entries,
Truncated: truncated,
}, nil
}

func buildConversation(ctx context.Context, logID string, limit int, getByID entryLookup, getPage sessionPageLookup, findByResponseID, findByPreviousResponseID entryLookup) (*ConversationResult, error) {
anchor, err := getByID(ctx, logID)
if err != nil {
return nil, err
}
if anchor == nil {
return &ConversationResult{AnchorID: logID, Entries: []LogEntry{}}, nil
}
if strings.TrimSpace(anchor.SessionID) != "" {
return buildSessionConversation(ctx, anchor, limit, getPage)
}
return buildConversationThread(ctx, logID, limit,
func(context.Context, string) (*LogEntry, error) { return anchor, nil },
findByResponseID, findByPreviousResponseID)
}

// buildConversationThread walks the response-ID chain outward from the anchor
// entry: backward via previous_response_id, then forward via the entries that
Expand Down Expand Up @@ -90,7 +209,10 @@ func buildConversationThread(ctx context.Context, logID string, limit int, getBy
}

sort.Slice(thread, func(i, j int) bool {
return thread[i].Timestamp.Before(thread[j].Timestamp)
if !thread[i].Timestamp.Equal(thread[j].Timestamp) {
return thread[i].Timestamp.Before(thread[j].Timestamp)
}
return thread[i].ID < thread[j].ID
})

entries := make([]LogEntry, 0, len(thread))
Expand Down
Loading