Add session view support in the access log

This commit is contained in:
braginini
2026-06-30 19:04:04 +02:00
parent 8f9d35ef73
commit 041a8fe252
10 changed files with 856 additions and 9 deletions

View File

@@ -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,
})
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 <expr>) 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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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).