From 041a8fe252a06e26e8015b94e26c0d62ef5d8a42 Mon Sep 17 00:00:00 2001 From: braginini Date: Tue, 30 Jun 2026 19:04:04 +0200 Subject: [PATCH] Add session view support in the access log --- .../handlers/access_log_handler.go | 43 ++++ .../internals/modules/agentnetwork/manager.go | 15 ++ .../modules/agentnetwork/types/accesslog.go | 174 +++++++++++++- .../agentnetwork/types/accesslogfilter.go | 36 +++ management/server/store/sql_store.go | 87 ++++++- .../sql_store_agentnetwork_accesslog_test.go | 98 ++++++++ management/server/store/store.go | 1 + .../server/store/store_mock_agentnetwork.go | 16 ++ shared/management/http/api/openapi.yml | 224 ++++++++++++++++++ shared/management/http/api/types.gen.go | 171 +++++++++++++ 10 files changed, 856 insertions(+), 9 deletions(-) diff --git a/management/internals/modules/agentnetwork/handlers/access_log_handler.go b/management/internals/modules/agentnetwork/handlers/access_log_handler.go index cf33d6435..4484d8c91 100644 --- a/management/internals/modules/agentnetwork/handlers/access_log_handler.go +++ b/management/internals/modules/agentnetwork/handlers/access_log_handler.go @@ -16,6 +16,7 @@ import ( // agent-network access-log listing and the aggregated usage overview. func (h *handler) addAccessLogEndpoints(router *mux.Router) { router.HandleFunc("/agent-network/access-logs", h.listAccessLogs).Methods("GET", "OPTIONS") + router.HandleFunc("/agent-network/access-log-sessions", h.listAccessLogSessions).Methods("GET", "OPTIONS") router.HandleFunc("/agent-network/usage/overview", h.getUsageOverview).Methods("GET", "OPTIONS") } @@ -89,3 +90,45 @@ func (h *handler) listAccessLogs(w http.ResponseWriter, r *http.Request) { TotalPages: totalPages, }) } + +// listAccessLogSessions returns the access logs grouped by session: the page +// unit is a session (total counts sessions), each carrying an aggregate summary +// and its ordered entries. Accepts the same filters as listAccessLogs. +func (h *handler) listAccessLogSessions(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + var filter types.AgentNetworkAccessLogFilter + if err := filter.ParseFromRequest(r); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + sessions, total, err := h.manager.ListAccessLogSessions(r.Context(), userAuth.AccountId, userAuth.UserId, filter) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + data := make([]api.AgentNetworkAccessLogSession, 0, len(sessions)) + for _, sess := range sessions { + data = append(data, sess.ToAPIResponse()) + } + + pageSize := filter.GetLimit() + totalPages := 0 + if pageSize > 0 { + totalPages = int((total + int64(pageSize) - 1) / int64(pageSize)) + } + + util.WriteJSONObject(r.Context(), w, api.AgentNetworkAccessLogSessionsResponse{ + Data: data, + Page: filter.Page, + PageSize: pageSize, + TotalRecords: int(total), + TotalPages: totalPages, + }) +} diff --git a/management/internals/modules/agentnetwork/manager.go b/management/internals/modules/agentnetwork/manager.go index 03f3d2f34..d88e0d77c 100644 --- a/management/internals/modules/agentnetwork/manager.go +++ b/management/internals/modules/agentnetwork/manager.go @@ -75,6 +75,7 @@ type Manager interface { ListConsumption(ctx context.Context, accountID, userID string) ([]*types.Consumption, error) ListAccessLogs(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLog, int64, error) + ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) StartAccessLogCleanup(ctx context.Context, cleanupIntervalHours int) RecordConsumption(ctx context.Context, accountID string, kind types.ConsumptionDimension, dimID string, windowSeconds, tokensIn, tokensOut int64, costUSD float64) error @@ -694,6 +695,16 @@ func (m *managerImpl) ListAccessLogs(ctx context.Context, accountID, userID stri return m.store.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, accountID, filter) } +// ListAccessLogSessions returns a paginated, server-side-filtered page of +// agent-network access logs grouped by session, plus the total number of +// sessions matching the filter. +func (m *managerImpl) ListAccessLogSessions(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + if err := m.requirePermission(ctx, accountID, userID, operations.Read); err != nil { + return nil, 0, err + } + return m.store.GetAgentNetworkAccessLogSessions(ctx, store.LockingStrengthNone, accountID, filter) +} + // GetUsageOverview returns the filtered usage rows aggregated into time buckets // at the requested granularity, oldest-first. func (m *managerImpl) GetUsageOverview(ctx context.Context, accountID, userID string, filter types.AgentNetworkAccessLogFilter, granularity types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { @@ -877,6 +888,10 @@ func (*mockManager) ListAccessLogs(_ context.Context, _, _ string, _ types.Agent return nil, 0, nil } +func (*mockManager) ListAccessLogSessions(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter) ([]*types.AgentNetworkAccessLogSession, int64, error) { + return nil, 0, nil +} + func (*mockManager) GetUsageOverview(_ context.Context, _, _ string, _ types.AgentNetworkAccessLogFilter, _ types.UsageGranularity) ([]*types.AgentNetworkUsageBucket, error) { return nil, nil } diff --git a/management/internals/modules/agentnetwork/types/accesslog.go b/management/internals/modules/agentnetwork/types/accesslog.go index 4ffb52e89..0db336c84 100644 --- a/management/internals/modules/agentnetwork/types/accesslog.go +++ b/management/internals/modules/agentnetwork/types/accesslog.go @@ -12,10 +12,13 @@ import ( // indexed columns so the access-log surface can filter server-side by // user / group / provider / model / decision. type AgentNetworkAccessLog struct { + // The composite index idx_anal_acct_session_ts backs the session-grouped + // listing (GROUP BY session_id ORDER BY MAX(timestamp) within an account); + // the single-column indexes still back the flat filters/sorts. ID string `gorm:"primaryKey"` - AccountID string `gorm:"index"` + AccountID string `gorm:"index;index:idx_anal_acct_session_ts,priority:1"` ServiceID string `gorm:"index"` - Timestamp time.Time `gorm:"index"` + Timestamp time.Time `gorm:"index;index:idx_anal_acct_session_ts,priority:3"` UserID string `gorm:"index"` SourceIP string Method string @@ -28,12 +31,12 @@ type AgentNetworkAccessLog struct { BytesDownload int64 // Flattened LLM dimensions (queryable). Sourced from proxy metadata keys. - Provider string `gorm:"index"` // vendor, e.g. "openai" (llm.provider) - Model string `gorm:"index"` // llm.model - SessionID string `gorm:"index"` // llm.session_id — groups a conversation / coding session - ResolvedProviderID string `gorm:"index"` // llm.resolved_provider_id - SelectedPolicyID string `gorm:"index"` // llm.selected_policy_id - Decision string `gorm:"index"` // llm_policy.decision (allow/deny) + Provider string `gorm:"index"` // vendor, e.g. "openai" (llm.provider) + Model string `gorm:"index"` // llm.model + SessionID string `gorm:"index;index:idx_anal_acct_session_ts,priority:2"` // llm.session_id — groups a conversation / coding session + ResolvedProviderID string `gorm:"index"` // llm.resolved_provider_id + SelectedPolicyID string `gorm:"index"` // llm.selected_policy_id + Decision string `gorm:"index"` // llm_policy.decision (allow/deny) DenyReason string // llm_policy.reason (raw code, mapped in the UI) InputTokens int64 OutputTokens int64 @@ -103,6 +106,161 @@ func strPtr(s string) *string { return &s } +// AgentNetworkAccessLogSession is a session-grouped view of access-log entries: +// all requests sharing a session id (or, for a request the client sent no +// session id for, that single request keyed by its own row id) folded into one +// summary plus its ordered entries. Assembled in Go from a page of entries — it +// is not a stored table. +type AgentNetworkAccessLogSession struct { + SessionID string // empty for a session-less (singleton) request + UserID string + GroupIDs []string // union of the entries' authorising groups + StartedAt time.Time + EndedAt time.Time + RequestCount int + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CostUSD float64 + Providers []string // distinct vendors seen in the session + Models []string // distinct models seen in the session + Decision string // "deny" if any entry was denied, else "allow" + Entries []*AgentNetworkAccessLog +} + +// sessionKey is the grouping key for an entry: its session id, or — when the +// client sent none — its own row id, so session-less requests each form their +// own singleton group. Must match the SQL group key +// COALESCE(NULLIF(session_id, ”), id). +func sessionKey(e *AgentNetworkAccessLog) string { + if e.SessionID != "" { + return e.SessionID + } + return e.ID +} + +// FoldAccessLogSessions folds a page of entries into per-session summaries, +// preserving the order of orderedKeys (the already-sorted, already-paginated +// session keys from the store). Entries are expected pre-sorted by timestamp +// within each key. Aggregation (sums, distinct providers/models, deny rollup) +// happens here in Go rather than in SQL so the query stays engine-portable. +func FoldAccessLogSessions(orderedKeys []string, entries []*AgentNetworkAccessLog) []*AgentNetworkAccessLogSession { + byKey := make(map[string]*AgentNetworkAccessLogSession, len(orderedKeys)) + order := make([]*AgentNetworkAccessLogSession, 0, len(orderedKeys)) + for _, k := range orderedKeys { + if _, ok := byKey[k]; ok { + continue + } + sess := &AgentNetworkAccessLogSession{Decision: "allow"} + byKey[k] = sess + order = append(order, sess) + } + + type seen struct{ providers, models, groups map[string]struct{} } + seenBy := make(map[string]*seen, len(orderedKeys)) + + for _, e := range entries { + k := sessionKey(e) + sess, ok := byKey[k] + if !ok { + continue // entry outside the paged set; defensive + } + sk := seenBy[k] + if sk == nil { + sk = &seen{providers: map[string]struct{}{}, models: map[string]struct{}{}, groups: map[string]struct{}{}} + seenBy[k] = sk + sess.SessionID = e.SessionID + sess.UserID = e.UserID + sess.StartedAt = e.Timestamp + sess.EndedAt = e.Timestamp + } + + sess.RequestCount++ + sess.InputTokens += e.InputTokens + sess.OutputTokens += e.OutputTokens + sess.TotalTokens += e.TotalTokens + sess.CostUSD += e.CostUSD + if e.Timestamp.Before(sess.StartedAt) { + sess.StartedAt = e.Timestamp + } + if e.Timestamp.After(sess.EndedAt) { + sess.EndedAt = e.Timestamp + } + if sess.UserID == "" { + sess.UserID = e.UserID + } + if e.Decision == "deny" { + sess.Decision = "deny" + } + if e.Provider != "" { + if _, dup := sk.providers[e.Provider]; !dup { + sk.providers[e.Provider] = struct{}{} + sess.Providers = append(sess.Providers, e.Provider) + } + } + if e.Model != "" { + if _, dup := sk.models[e.Model]; !dup { + sk.models[e.Model] = struct{}{} + sess.Models = append(sess.Models, e.Model) + } + } + for _, g := range e.GroupIDs { + if g == "" { + continue + } + if _, dup := sk.groups[g]; !dup { + sk.groups[g] = struct{}{} + sess.GroupIDs = append(sess.GroupIDs, g) + } + } + sess.Entries = append(sess.Entries, e) + } + + out := make([]*AgentNetworkAccessLogSession, 0, len(order)) + for _, sess := range order { + if sess.RequestCount > 0 { + out = append(out, sess) + } + } + return out +} + +// ToAPIResponse renders the session summary (and its entries) as the API +// representation. +func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccessLogSession { + entries := make([]api.AgentNetworkAccessLog, 0, len(sess.Entries)) + for _, e := range sess.Entries { + entries = append(entries, e.ToAPIResponse()) + } + + out := api.AgentNetworkAccessLogSession{ + StartedAt: sess.StartedAt, + EndedAt: sess.EndedAt, + RequestCount: sess.RequestCount, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + TotalTokens: sess.TotalTokens, + CostUsd: sess.CostUSD, + Decision: sess.Decision, + Entries: entries, + } + out.SessionId = strPtr(sess.SessionID) + out.UserId = strPtr(sess.UserID) + if len(sess.Providers) > 0 { + providers := sess.Providers + out.Providers = &providers + } + if len(sess.Models) > 0 { + models := sess.Models + out.Models = &models + } + if len(sess.GroupIDs) > 0 { + groups := sess.GroupIDs + out.GroupIds = &groups + } + return out +} + // AgentNetworkAccessLogGroup is the normalised many-to-many row linking a log // entry to one authorising group, so the access-log endpoint can filter by // group with a simple `group_id IN (...)` join instead of substring-matching a diff --git a/management/internals/modules/agentnetwork/types/accesslogfilter.go b/management/internals/modules/agentnetwork/types/accesslogfilter.go index 2906e7afa..8acefe059 100644 --- a/management/internals/modules/agentnetwork/types/accesslogfilter.go +++ b/management/internals/modules/agentnetwork/types/accesslogfilter.go @@ -60,6 +60,25 @@ var accessLogSortFields = map[string]string{ "decision": "decision", } +// sessionSortExprs maps the API sort_by values to the aggregate expression a +// session-grouped query sorts on. A session has no single row, so per-row +// columns become aggregates: "timestamp" (the default) is the session's last +// activity, "started_at" its first. Every expression is a plain SQL aggregate +// over the GROUP BY, so the ordering stays portable across SQLite and Postgres. +// Keys absent here (e.g. "model", "provider") fall back to the default — the +// grouped UI only offers the session-level sorts below. +var sessionSortExprs = map[string]string{ + "timestamp": "MAX(timestamp)", + "started_at": "MIN(timestamp)", + "cost_usd": "SUM(cost_usd)", + "total_tokens": "SUM(total_tokens)", + "duration": "SUM(duration)", + "request_count": "COUNT(*)", + "status_code": "MAX(status_code)", + "user_id": "MIN(user_id)", + "decision": "MAX(decision)", // "deny" > "allow": DESC surfaces denied sessions first +} + // AgentNetworkAccessLogFilter holds pagination, filtering and sorting // parameters for the agent-network access-log listing. Group / provider / // model are multi-valued (the UI uses multi-select; an entry matches when it @@ -126,6 +145,16 @@ func (f *AgentNetworkAccessLogFilter) GetSortColumn() string { return accessLogSortFields[accessLogDefaultSortBy] } +// GetSessionSortExpr returns the aggregate ORDER BY expression for the active +// sort field when listing session-grouped logs. Unknown / non-session sort +// fields fall back to the default (last activity). +func (f *AgentNetworkAccessLogFilter) GetSessionSortExpr() string { + if expr, ok := sessionSortExprs[f.SortBy]; ok { + return expr + } + return sessionSortExprs[accessLogDefaultSortBy] +} + // GetSortOrder returns the normalised sort order ("ASC"/"DESC"). func (f *AgentNetworkAccessLogFilter) GetSortOrder() string { if strings.EqualFold(f.SortOrder, "asc") { @@ -166,6 +195,13 @@ func parseAccessLogSortField(s string) string { if _, ok := accessLogSortFields[s]; ok { return s } + // Session-grouped listings sort on aggregates (e.g. request_count, + // started_at) that aren't flat-row columns; accept those too. The flat + // listing maps any unknown field back to the default, so this stays safe + // for the non-grouped endpoint. + if _, ok := sessionSortExprs[s]; ok { + return s + } return accessLogDefaultSortBy } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 69b075fd3..18be1b6ed 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -33,11 +33,11 @@ import ( "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain" + agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" "github.com/netbirdio/netbird/management/internals/modules/zones" "github.com/netbirdio/netbird/management/internals/modules/zones/records" - agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types" resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types" routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types" networkTypes "github.com/netbirdio/netbird/management/server/networks/types" @@ -5827,6 +5827,91 @@ func (s *SqlStore) hydrateAgentNetworkAccessLogGroups(ctx context.Context, accou return nil } +// agentNetworkSessionKeyExpr is the SQL group key for session-grouped access +// logs: the row's session id, or — when the client sent none — the row id, so +// session-less requests each form their own singleton group. COALESCE/NULLIF +// are standard SQL, so this stays portable across SQLite and Postgres. +const agentNetworkSessionKeyExpr = "COALESCE(NULLIF(session_id, ''), id)" + +// GetAgentNetworkAccessLogSessions retrieves agent-network access logs grouped +// by session, with server-side pagination, filtering and sorting at the session +// level. It paginates over the distinct session keys (ordered by the requested +// session-level aggregate), fetches every entry for the page's sessions, and +// folds them into per-session summaries. The returned count is the number of +// matching sessions. Filters apply to the entries, so a session's summary +// reflects only its filter-matching requests. +func (s *SqlStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + // Count distinct sessions via a grouped subquery — portable and avoids + // relying on COUNT(DISTINCT ) quoting quirks. + sessionsSubquery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr) + + var totalCount int64 + if err := s.db.Table("(?) AS sessions", sessionsSubquery).Count(&totalCount).Error; err != nil { + log.WithContext(ctx).Errorf("failed to count agent-network access-log sessions: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to count agent-network access-log sessions") + } + + // The page of session keys, ordered by the session-level aggregate. The + // session-key tiebreaker keeps pagination deterministic when the primary + // aggregate ties. + type sessionKeyRow struct { + SessionKey string + } + var keyRows []sessionKeyRow + keyQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).Where(accountIDCondition, accountID), + filter, + ). + Select(agentNetworkSessionKeyExpr + " AS session_key"). + Group(agentNetworkSessionKeyExpr). + Order(filter.GetSessionSortExpr() + " " + filter.GetSortOrder()). + Order("session_key ASC"). + Limit(filter.GetLimit()). + Offset(filter.GetOffset()) + if err := keyQuery.Scan(&keyRows).Error; err != nil { + log.WithContext(ctx).Errorf("failed to list agent-network access-log session keys: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to list agent-network access-log session keys") + } + if len(keyRows) == 0 { + return nil, totalCount, nil + } + + keys := make([]string, 0, len(keyRows)) + for _, r := range keyRows { + keys = append(keys, r.SessionKey) + } + + // All entries for the page's sessions, contiguous per session and oldest + // first within each — the fold relies on that ordering. + var entries []*agentNetworkTypes.AgentNetworkAccessLog + entriesQuery := s.applyAgentNetworkAccessLogFilters( + s.db.Where(accountIDCondition, accountID), + filter, + ). + Where(agentNetworkSessionKeyExpr+" IN ?", keys). + Order(agentNetworkSessionKeyExpr + ", timestamp ASC") + + if lockStrength != LockingStrengthNone { + entriesQuery = entriesQuery.Clauses(clause.Locking{Strength: string(lockStrength)}) + } + + if err := entriesQuery.Find(&entries).Error; err != nil { + log.WithContext(ctx).Errorf("failed to get agent-network access-log session entries: %v", err) + return nil, 0, status.Errorf(status.Internal, "failed to get agent-network access-log session entries") + } + + if err := s.hydrateAgentNetworkAccessLogGroups(ctx, accountID, entries); err != nil { + return nil, 0, err + } + + return agentNetworkTypes.FoldAccessLogSessions(keys, entries), totalCount, nil +} + // GetAccountAccessLogs retrieves access logs for a given account with pagination and filtering func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter accesslogs.AccessLogFilter) ([]*accesslogs.AccessLogEntry, int64, error) { var logs []*accesslogs.AccessLogEntry diff --git a/management/server/store/sql_store_agentnetwork_accesslog_test.go b/management/server/store/sql_store_agentnetwork_accesslog_test.go index 821f6f725..0cdca43d2 100644 --- a/management/server/store/sql_store_agentnetwork_accesslog_test.go +++ b/management/server/store/sql_store_agentnetwork_accesslog_test.go @@ -155,6 +155,104 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) { assert.Equal(t, "u3", filtered[0].ID) } +// TestAgentNetworkAccessLogSessions_RealStore drives GetAgentNetworkAccessLogSessions +// against a real sqlite store: session grouping + aggregation, recency ordering, +// singleton groups for session-less requests, session pagination, the model +// filter narrowing sessions, and aggregate sorting. +func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) { + ctx := context.Background() + s, cleanup, err := NewTestStoreFromSQL(ctx, "", t.TempDir()) + require.NoError(t, err, "real sqlite test store must come up") + defer cleanup() + + const accountID = "acc-anet-sessions-1" + base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC) + at := func(h int) time.Time { return base.Add(time.Duration(h) * time.Hour) } + + mk := func(id, session, user, provider, model, decision string, ts time.Time, cost float64) *agentNetworkTypes.AgentNetworkAccessLog { + return &agentNetworkTypes.AgentNetworkAccessLog{ + ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts, + UserID: user, StatusCode: 200, Provider: provider, Model: model, + SessionID: session, Decision: decision, + InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost, + } + } + + // Two-request session s1 (alice), a one-request denied session s2 (bob), and + // two session-less requests (empty session id) that must each form their own + // singleton group. + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-a", "s1", "alice", "openai", "gpt-4o", "allow", at(1), 0.10), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-a", GroupID: "grp-eng", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s1-b", "s1", "alice", "openai", "gpt-4o", "allow", at(2), 0.20), + []agentNetworkTypes.AgentNetworkAccessLogGroup{{LogID: "s1-b", GroupID: "grp-oncall", AccountID: accountID}})) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("s2-a", "s2", "bob", "anthropic", "claude-3", "deny", at(3), 0.05), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-old", "", "carol", "openai", "o1", "allow", at(0), 0.01), nil)) + require.NoError(t, s.CreateAgentNetworkAccessLog(ctx, mk("se-new", "", "dave", "mistral", "mistral-large", "allow", at(4), 0.02), nil)) + + // Default sort: last activity (MAX timestamp) descending. + sessions, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "four sessions: s1, s2, and two singletons") + require.Len(t, sessions, 4) + + // se-new(t4) > s2(t3) > s1(t2) > se-old(t0) + assert.Equal(t, "", sessions[0].SessionID, "newest is a session-less singleton") + assert.Equal(t, "se-new", sessions[0].Entries[0].ID) + assert.Equal(t, "s2", sessions[1].SessionID) + assert.Equal(t, "s1", sessions[2].SessionID) + assert.Equal(t, "se-old", sessions[3].Entries[0].ID) + + // s1 aggregation. + s1 := sessions[2] + assert.Equal(t, 2, s1.RequestCount, "s1 has two requests") + assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session") + assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session") + assert.Equal(t, "alice", s1.UserID) + assert.Equal(t, "allow", s1.Decision) + assert.Equal(t, at(1), s1.StartedAt, "started = earliest entry") + assert.Equal(t, at(2), s1.EndedAt, "ended = latest entry") + assert.ElementsMatch(t, []string{"openai"}, s1.Providers) + assert.ElementsMatch(t, []string{"gpt-4o"}, s1.Models) + assert.ElementsMatch(t, []string{"grp-eng", "grp-oncall"}, s1.GroupIDs, "union of the entries' authorising groups") + + // Denied session rolls up to deny. + assert.Equal(t, "deny", sessions[1].Decision, "any denied request makes the session deny") + + // Pagination over sessions: 2 per page. + page1, total, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 2}) + require.NoError(t, err) + assert.Equal(t, int64(4), total, "total still counts all sessions") + require.Len(t, page1, 2) + assert.Equal(t, "se-new", page1[0].Entries[0].ID) + assert.Equal(t, "s2", page1[1].SessionID) + + page2, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 2, PageSize: 2}) + require.NoError(t, err) + require.Len(t, page2, 2) + assert.Equal(t, "s1", page2[0].SessionID) + assert.Equal(t, "se-old", page2[1].Entries[0].ID) + + // Model filter narrows to the session(s) with matching entries. + model := "claude-3" + filtered, fTotal, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, Models: []string{model}}) + require.NoError(t, err) + assert.Equal(t, int64(1), fTotal, "only s2 has a claude-3 request") + require.Len(t, filtered, 1) + assert.Equal(t, "s2", filtered[0].SessionID) + + // Sort by total session cost, descending: s1 (0.30) leads despite not being + // the most recent. + byCost, _, err := s.GetAgentNetworkAccessLogSessions(ctx, LockingStrengthNone, accountID, + agentNetworkTypes.AgentNetworkAccessLogFilter{Page: 1, PageSize: 50, SortBy: "cost_usd", SortOrder: "desc"}) + require.NoError(t, err) + require.Len(t, byCost, 4) + assert.Equal(t, "s1", byCost[0].SessionID, "highest-cost session sorts first") +} + // TestDeleteOldAgentNetworkAccessLogs verifies the retention sweep removes only // access-log rows (and their group children) older than the cutoff, leaving // recent rows — and never touching usage records. diff --git a/management/server/store/store.go b/management/server/store/store.go index f678ccc89..908c199f5 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -304,6 +304,7 @@ type Store interface { CreateAgentNetworkAccessLog(ctx context.Context, entry *agentNetworkTypes.AgentNetworkAccessLog, groups []agentNetworkTypes.AgentNetworkAccessLogGroup) error CreateAgentNetworkUsage(ctx context.Context, usage *agentNetworkTypes.AgentNetworkUsage, groups []agentNetworkTypes.AgentNetworkUsageGroup) error GetAgentNetworkAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLog, int64, error) + GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error) GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error) diff --git a/management/server/store/store_mock_agentnetwork.go b/management/server/store/store_mock_agentnetwork.go index 1ca866e63..18adf20f0 100644 --- a/management/server/store/store_mock_agentnetwork.go +++ b/management/server/store/store_mock_agentnetwork.go @@ -433,6 +433,22 @@ func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogs(ctx, lockStrength, ac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogs), ctx, lockStrength, accountID, filter) } +// GetAgentNetworkAccessLogSessions mocks base method. +func (m *MockStore) GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAgentNetworkAccessLogSessions", ctx, lockStrength, accountID, filter) + ret0, _ := ret[0].([]*agentNetworkTypes.AgentNetworkAccessLogSession) + ret1, _ := ret[1].(int64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetAgentNetworkAccessLogSessions indicates an expected call of GetAgentNetworkAccessLogSessions. +func (mr *MockStoreMockRecorder) GetAgentNetworkAccessLogSessions(ctx, lockStrength, accountID, filter interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAgentNetworkAccessLogSessions", reflect.TypeOf((*MockStore)(nil).GetAgentNetworkAccessLogSessions), ctx, lockStrength, accountID, filter) +} + // GetAgentNetworkUsageRows mocks base method. func (m *MockStore) GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error) { m.ctrl.T.Helper() diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 3a7a270e6..dffb7d7de 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5820,6 +5820,115 @@ components: - page_size - total_records - total_pages + AgentNetworkAccessLogSession: + type: object + description: A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. + properties: + session_id: + type: string + description: Conversation / coding-session identifier shared by the entries. Empty for a session-less (singleton) request grouped on its own id. + example: "019eeb72-ab7c-7cd2-aa05-6e8eb834afcb" + user_id: + type: string + description: NetBird user id of the session's caller. + group_ids: + type: array + items: + type: string + description: Union of the authorising group ids across the session's entries. + started_at: + type: string + format: date-time + description: Timestamp of the session's earliest request. + example: "2026-05-05T12:30:00Z" + ended_at: + type: string + format: date-time + description: Timestamp of the session's latest request. + example: "2026-05-05T12:34:56Z" + request_count: + type: integer + description: Number of requests in the session. + example: 7 + input_tokens: + type: integer + format: int64 + description: Total input (prompt) tokens across the session. + example: 8400 + output_tokens: + type: integer + format: int64 + description: Total output (completion) tokens across the session. + example: 4480 + total_tokens: + type: integer + format: int64 + description: Total tokens across the session. + example: 12880 + cost_usd: + type: number + format: double + description: Total estimated USD cost across the session. + example: 0.1617 + providers: + type: array + items: + type: string + description: Distinct LLM provider vendors seen in the session. + models: + type: array + items: + type: string + description: Distinct models seen in the session. + decision: + type: string + description: Session decision — "deny" if any request was denied, otherwise "allow". + example: "allow" + entries: + type: array + description: The session's access-log entries, oldest first. + items: + $ref: "#/components/schemas/AgentNetworkAccessLog" + required: + - started_at + - ended_at + - request_count + - input_tokens + - output_tokens + - total_tokens + - cost_usd + - decision + - entries + AgentNetworkAccessLogSessionsResponse: + type: object + properties: + data: + type: array + description: List of session-grouped agent-network access logs. + items: + $ref: "#/components/schemas/AgentNetworkAccessLogSession" + page: + type: integer + description: Current page number. + example: 1 + page_size: + type: integer + description: Number of sessions per page. + example: 50 + total_records: + type: integer + description: Total number of sessions matching the filter. + example: 124 + total_pages: + type: integer + description: Total number of pages available. + example: 3 + required: + - data + - page + - page_size + - total_records + - total_pages AgentNetworkUsageBucket: type: object description: One aggregated agent-network usage time bucket (UTC). The bucket width is set by the request's granularity. @@ -13122,6 +13231,121 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" + /api/agent-network/access-log-sessions: + get: + summary: List Agent Network access logs grouped by session + description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. + tags: [ Agent Network ] + security: + - BearerAuth: [ ] + - TokenAuth: [ ] + parameters: + - in: query + name: page + schema: + type: integer + default: 1 + minimum: 1 + description: Page number for pagination (1-indexed). + - in: query + name: page_size + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + description: Number of sessions per page (max 100). + - in: query + name: sort_by + schema: + type: string + enum: [timestamp, started_at, cost_usd, total_tokens, duration, request_count, status_code, user_id, decision] + default: timestamp + description: Session-level field to sort by. "timestamp" is the session's last activity, "started_at" its first. + - in: query + name: sort_order + schema: + type: string + enum: [asc, desc] + default: desc + description: Sort order (ascending or descending). + - in: query + name: search + schema: + type: string + description: General search across log ID, host, path, model, and user email/name. + - in: query + name: user_id + schema: + type: string + description: Filter by authenticated user ID. + - in: query + name: session_id + schema: + type: string + description: Filter to a single conversation / coding session id. + - in: query + name: group_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by authorising group id. Repeat for multiple (matches any). + - in: query + name: provider_id + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by resolved provider id. Repeat for multiple (matches any). + - in: query + name: model + schema: + type: array + items: + type: string + style: form + explode: true + description: Filter by model. Repeat for multiple (matches any). + - in: query + name: decision + schema: + type: string + description: Filter by policy decision (e.g. allow, deny). + - in: query + name: path + schema: + type: string + description: Filter by request path prefix (matches entries whose path starts with this value). + - in: query + name: start_date + schema: + type: string + format: date-time + description: Filter by timestamp >= start_date (RFC3339 format). + - in: query + name: end_date + schema: + type: string + format: date-time + description: Filter by timestamp <= end_date (RFC3339 format). + responses: + '200': + description: Paginated list of session-grouped agent-network access logs + content: + application/json: + schema: + $ref: "#/components/schemas/AgentNetworkAccessLogSessionsResponse" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" /api/agent-network/usage/overview: get: summary: Agent Network usage overview diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 84ad60e1c..7ea9514c0 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1202,6 +1202,63 @@ func (e WorkloadType) Valid() bool { } } +// Defines values for GetApiAgentNetworkAccessLogSessionsParamsSortBy. +const ( + GetApiAgentNetworkAccessLogSessionsParamsSortByCostUsd GetApiAgentNetworkAccessLogSessionsParamsSortBy = "cost_usd" + GetApiAgentNetworkAccessLogSessionsParamsSortByDecision GetApiAgentNetworkAccessLogSessionsParamsSortBy = "decision" + GetApiAgentNetworkAccessLogSessionsParamsSortByDuration GetApiAgentNetworkAccessLogSessionsParamsSortBy = "duration" + GetApiAgentNetworkAccessLogSessionsParamsSortByRequestCount GetApiAgentNetworkAccessLogSessionsParamsSortBy = "request_count" + GetApiAgentNetworkAccessLogSessionsParamsSortByStartedAt GetApiAgentNetworkAccessLogSessionsParamsSortBy = "started_at" + GetApiAgentNetworkAccessLogSessionsParamsSortByStatusCode GetApiAgentNetworkAccessLogSessionsParamsSortBy = "status_code" + GetApiAgentNetworkAccessLogSessionsParamsSortByTimestamp GetApiAgentNetworkAccessLogSessionsParamsSortBy = "timestamp" + GetApiAgentNetworkAccessLogSessionsParamsSortByTotalTokens GetApiAgentNetworkAccessLogSessionsParamsSortBy = "total_tokens" + GetApiAgentNetworkAccessLogSessionsParamsSortByUserId GetApiAgentNetworkAccessLogSessionsParamsSortBy = "user_id" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogSessionsParamsSortBy enum. +func (e GetApiAgentNetworkAccessLogSessionsParamsSortBy) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogSessionsParamsSortByCostUsd: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByDecision: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByDuration: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByRequestCount: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByStartedAt: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByStatusCode: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByTimestamp: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByTotalTokens: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortByUserId: + return true + default: + return false + } +} + +// Defines values for GetApiAgentNetworkAccessLogSessionsParamsSortOrder. +const ( + GetApiAgentNetworkAccessLogSessionsParamsSortOrderAsc GetApiAgentNetworkAccessLogSessionsParamsSortOrder = "asc" + GetApiAgentNetworkAccessLogSessionsParamsSortOrderDesc GetApiAgentNetworkAccessLogSessionsParamsSortOrder = "desc" +) + +// Valid indicates whether the value is a known member of the GetApiAgentNetworkAccessLogSessionsParamsSortOrder enum. +func (e GetApiAgentNetworkAccessLogSessionsParamsSortOrder) Valid() bool { + switch e { + case GetApiAgentNetworkAccessLogSessionsParamsSortOrderAsc: + return true + case GetApiAgentNetworkAccessLogSessionsParamsSortOrderDesc: + return true + default: + return false + } +} + // Defines values for GetApiAgentNetworkAccessLogsParamsSortBy. const ( GetApiAgentNetworkAccessLogsParamsSortByCostUsd GetApiAgentNetworkAccessLogsParamsSortBy = "cost_usd" @@ -1736,6 +1793,69 @@ type AgentNetworkAccessLog struct { UserId *string `json:"user_id,omitempty"` } +// AgentNetworkAccessLogSession A session-grouped view of agent-network access logs — all requests sharing a session id (or a single session-less request) folded into one summary plus its ordered entries. +type AgentNetworkAccessLogSession struct { + // CostUsd Total estimated USD cost across the session. + CostUsd float64 `json:"cost_usd"` + + // Decision Session decision — "deny" if any request was denied, otherwise "allow". + Decision string `json:"decision"` + + // EndedAt Timestamp of the session's latest request. + EndedAt time.Time `json:"ended_at"` + + // Entries The session's access-log entries, oldest first. + Entries []AgentNetworkAccessLog `json:"entries"` + + // GroupIds Union of the authorising group ids across the session's entries. + GroupIds *[]string `json:"group_ids,omitempty"` + + // InputTokens Total input (prompt) tokens across the session. + InputTokens int64 `json:"input_tokens"` + + // Models Distinct models seen in the session. + Models *[]string `json:"models,omitempty"` + + // OutputTokens Total output (completion) tokens across the session. + OutputTokens int64 `json:"output_tokens"` + + // Providers Distinct LLM provider vendors seen in the session. + Providers *[]string `json:"providers,omitempty"` + + // RequestCount Number of requests in the session. + RequestCount int `json:"request_count"` + + // SessionId Conversation / coding-session identifier shared by the entries. Empty for a session-less (singleton) request grouped on its own id. + SessionId *string `json:"session_id,omitempty"` + + // StartedAt Timestamp of the session's earliest request. + StartedAt time.Time `json:"started_at"` + + // TotalTokens Total tokens across the session. + TotalTokens int64 `json:"total_tokens"` + + // UserId NetBird user id of the session's caller. + UserId *string `json:"user_id,omitempty"` +} + +// AgentNetworkAccessLogSessionsResponse defines model for AgentNetworkAccessLogSessionsResponse. +type AgentNetworkAccessLogSessionsResponse struct { + // Data List of session-grouped agent-network access logs. + Data []AgentNetworkAccessLogSession `json:"data"` + + // Page Current page number. + Page int `json:"page"` + + // PageSize Number of sessions per page. + PageSize int `json:"page_size"` + + // TotalPages Total number of pages available. + TotalPages int `json:"total_pages"` + + // TotalRecords Total number of sessions matching the filter. + TotalRecords int `json:"total_records"` +} + // AgentNetworkAccessLogsResponse defines model for AgentNetworkAccessLogsResponse. type AgentNetworkAccessLogsResponse struct { // Data List of agent-network access log entries. @@ -5567,6 +5687,57 @@ type bearerAuthContextKey string // tokenAuthContextKey is the context key for TokenAuth security scheme type tokenAuthContextKey string +// GetApiAgentNetworkAccessLogSessionsParams defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParams struct { + // Page Page number for pagination (1-indexed). + Page *int `form:"page,omitempty" json:"page,omitempty"` + + // PageSize Number of sessions per page (max 100). + PageSize *int `form:"page_size,omitempty" json:"page_size,omitempty"` + + // SortBy Session-level field to sort by. "timestamp" is the session's last activity, "started_at" its first. + SortBy *GetApiAgentNetworkAccessLogSessionsParamsSortBy `form:"sort_by,omitempty" json:"sort_by,omitempty"` + + // SortOrder Sort order (ascending or descending). + SortOrder *GetApiAgentNetworkAccessLogSessionsParamsSortOrder `form:"sort_order,omitempty" json:"sort_order,omitempty"` + + // Search General search across log ID, host, path, model, and user email/name. + Search *string `form:"search,omitempty" json:"search,omitempty"` + + // UserId Filter by authenticated user ID. + UserId *string `form:"user_id,omitempty" json:"user_id,omitempty"` + + // SessionId Filter to a single conversation / coding session id. + SessionId *string `form:"session_id,omitempty" json:"session_id,omitempty"` + + // GroupId Filter by authorising group id. Repeat for multiple (matches any). + GroupId *[]string `form:"group_id,omitempty" json:"group_id,omitempty"` + + // ProviderId Filter by resolved provider id. Repeat for multiple (matches any). + ProviderId *[]string `form:"provider_id,omitempty" json:"provider_id,omitempty"` + + // Model Filter by model. Repeat for multiple (matches any). + Model *[]string `form:"model,omitempty" json:"model,omitempty"` + + // Decision Filter by policy decision (e.g. allow, deny). + Decision *string `form:"decision,omitempty" json:"decision,omitempty"` + + // Path Filter by request path prefix (matches entries whose path starts with this value). + Path *string `form:"path,omitempty" json:"path,omitempty"` + + // StartDate Filter by timestamp >= start_date (RFC3339 format). + StartDate *time.Time `form:"start_date,omitempty" json:"start_date,omitempty"` + + // EndDate Filter by timestamp <= end_date (RFC3339 format). + EndDate *time.Time `form:"end_date,omitempty" json:"end_date,omitempty"` +} + +// GetApiAgentNetworkAccessLogSessionsParamsSortBy defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParamsSortBy string + +// GetApiAgentNetworkAccessLogSessionsParamsSortOrder defines parameters for GetApiAgentNetworkAccessLogSessions. +type GetApiAgentNetworkAccessLogSessionsParamsSortOrder string + // GetApiAgentNetworkAccessLogsParams defines parameters for GetApiAgentNetworkAccessLogs. type GetApiAgentNetworkAccessLogsParams struct { // Page Page number for pagination (1-indexed).