mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 15:49:06 +02:00
Add session view support in the access log
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user