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
@@ -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,
})
}
@@ -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
}
@@ -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
@@ -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
}
+86 -1
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
@@ -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.
+1
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)
@@ -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()