diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ce8e342ab0..ebdc4de4cb1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15634,6 +15634,13 @@ const docTemplate = `{ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + }, "network_calls": { "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 92eecc3601c..2dedb93ab79 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13928,6 +13928,13 @@ "type": "string" } }, + "network_call_logs": { + "description": "NetworkCallLogs is the chronological list of individual network calls made\nduring the session, holding the earliest calls up to a server-side cap.\nNetworkCalls remains authoritative for whole-session totals, so a shorter\nlist than NetworkCalls.Total means the list was truncated. Empty when the\nsession did not pass through Agent Firewall.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AgentFirewallLog" + } + }, "network_calls": { "description": "NetworkCalls summarizes the Agent Firewall network calls made during the\nsession. A nil value means the session did not pass through Agent\nFirewall, so network call monitoring was not active, which the UI\nsurfaces as \"Disabled\".", "allOf": [ diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 75deb23053d..445f0222424 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1142,34 +1142,43 @@ func AIBridgeSession(row database.ListAIBridgeSessionsRow) codersdk.AIBridgeSess return session } +// AIBridgeSessionThreadsParams groups the session row and its subresources for +// AIBridgeSessionThreads. Named fields avoid transposing the several adjacent +// slice arguments (notably TopDomains and NetworkCalls) at the call site. +type AIBridgeSessionThreadsParams struct { + Session database.ListAIBridgeSessionsRow + Interceptions []database.ListAIBridgeSessionThreadsRow + TokenUsages []database.AIBridgeTokenUsage + ToolUsages []database.AIBridgeToolUsage + UserPrompts []database.AIBridgeUserPrompt + ModelThoughts []database.AIBridgeModelThought + TopDomains []database.GetAIBridgeSessionTopDomainsRow + NetworkCalls []database.BoundaryLog +} + // AIBridgeSessionThreads converts session metadata and thread interceptions // into the threads response. It groups interceptions into threads, builds // agentic actions from tool usages and model thoughts, and aggregates // token usage with metadata. -func AIBridgeSessionThreads( - session database.ListAIBridgeSessionsRow, - interceptions []database.ListAIBridgeSessionThreadsRow, - tokenUsages []database.AIBridgeTokenUsage, - toolUsages []database.AIBridgeToolUsage, - userPrompts []database.AIBridgeUserPrompt, - modelThoughts []database.AIBridgeModelThought, - topDomains []database.GetAIBridgeSessionTopDomainsRow, -) codersdk.AIBridgeSessionThreadsResponse { +func AIBridgeSessionThreads(p AIBridgeSessionThreadsParams) codersdk.AIBridgeSessionThreadsResponse { + session := p.Session + interceptions := p.Interceptions + // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) - for _, tu := range tokenUsages { + for _, tu := range p.TokenUsages { tokensByInterception[tu.InterceptionID] = append(tokensByInterception[tu.InterceptionID], tu) } toolsByInterception := make(map[uuid.UUID][]database.AIBridgeToolUsage, len(interceptions)) - for _, tu := range toolUsages { + for _, tu := range p.ToolUsages { toolsByInterception[tu.InterceptionID] = append(toolsByInterception[tu.InterceptionID], tu) } promptsByInterception := make(map[uuid.UUID][]database.AIBridgeUserPrompt, len(interceptions)) - for _, up := range userPrompts { + for _, up := range p.UserPrompts { promptsByInterception[up.InterceptionID] = append(promptsByInterception[up.InterceptionID], up) } thoughtsByInterception := make(map[uuid.UUID][]database.AIBridgeModelThought, len(interceptions)) - for _, mt := range modelThoughts { + for _, mt := range p.ModelThoughts { thoughtsByInterception[mt.InterceptionID] = append(thoughtsByInterception[mt.InterceptionID], mt) } @@ -1207,7 +1216,7 @@ func AIBridgeSessionThreads( // Aggregate session-level token usage metadata from all token // usages in the session (not just the page). - sessionTokenMeta := aggregateTokenMetadata(tokenUsages) + sessionTokenMeta := aggregateTokenMetadata(p.TokenUsages) resp := codersdk.AIBridgeSessionThreadsResponse{ ID: session.SessionID, @@ -1253,7 +1262,7 @@ func AIBridgeSessionThreads( Blocked: session.NetworkCallsBlocked, } } - for _, d := range topDomains { + for _, d := range p.TopDomains { resp.NetworkTopDomains = append(resp.NetworkTopDomains, codersdk.AIBridgeSessionNetworkDomain{ Domain: d.Domain, Count: d.Count, @@ -1262,9 +1271,34 @@ func AIBridgeSessionThreads( // from the last row processed. resp.NetworkDomainCount = d.TotalDomains } + resp.NetworkCallLogs = AgentFirewallLogs(p.NetworkCalls) return resp } +// AgentFirewallLogs converts boundary logs to their SDK representation. +// Allowed is derived from MatchedRule being non-NULL. +func AgentFirewallLogs(logs []database.BoundaryLog) []codersdk.AgentFirewallLog { + results := make([]codersdk.AgentFirewallLog, 0, len(logs)) + for _, l := range logs { + bl := codersdk.AgentFirewallLog{ + ID: l.ID, + SessionID: l.SessionID, + SequenceNumber: l.SequenceNumber, + Allowed: l.MatchedRule.Valid, + CreatedAt: l.CreatedAt, + Proto: l.Proto, + Method: l.Method, + Detail: l.Detail, + CapturedAt: &l.CapturedAt, + } + if l.MatchedRule.Valid { + bl.MatchedRule = &l.MatchedRule.String + } + results = append(results, bl) + } + return results +} + func buildAIBridgeThread( threadID uuid.UUID, interceptions []database.AIBridgeInterception, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index e20aa3a972c..e0eab762a8a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6836,6 +6836,13 @@ func (q *querier) ListAIBridgeModels(ctx context.Context, arg database.ListAIBri return q.db.ListAuthorizedAIBridgeModels(ctx, arg, prep) } +func (q *querier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { + return nil, err + } + return q.db.ListAIBridgeSessionNetworkCalls(ctx, arg) +} + func (q *querier) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { prep, err := prepareSQLFilter(ctx, q.auth, policy.ActionRead, rbac.ResourceAibridgeInterception.Type) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e4e4264ff65..2cb8a82191d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6975,6 +6975,12 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.GetAIBridgeSessionTopDomainsRow{}) })) + s.Run("ListAIBridgeSessionNetworkCalls", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.ListAIBridgeSessionNetworkCallsParams{SessionID: "sess", Limit: 1000} + db.EXPECT().ListAIBridgeSessionNetworkCalls(gomock.Any(), params).Return([]database.BoundaryLog{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.BoundaryLog{}) + })) + s.Run("ListAIBridgeTokenUsagesByInterceptionIDs", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { ids := []uuid.UUID{{1}} db.EXPECT().ListAIBridgeTokenUsagesByInterceptionIDs(gomock.Any(), ids).Return([]database.AIBridgeTokenUsage{}, nil).AnyTimes() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index c87d7b8dba5..7ef3f158110 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -4777,6 +4777,14 @@ func (m queryMetricsStore) ListAIBridgeModels(ctx context.Context, arg database. return r0, r1 } +func (m queryMetricsStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + start := time.Now() + r0, r1 := m.s.ListAIBridgeSessionNetworkCalls(ctx, arg) + m.queryLatencies.WithLabelValues("ListAIBridgeSessionNetworkCalls").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "ListAIBridgeSessionNetworkCalls").Inc() + return r0, r1 +} + func (m queryMetricsStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { start := time.Now() r0, r1 := m.s.ListAIBridgeSessionThreads(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 1d2c128e998..03088ef9a84 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -8936,6 +8936,21 @@ func (mr *MockStoreMockRecorder) ListAIBridgeModels(ctx, arg any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeModels", reflect.TypeOf((*MockStore)(nil).ListAIBridgeModels), ctx, arg) } +// ListAIBridgeSessionNetworkCalls mocks base method. +func (m *MockStore) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg database.ListAIBridgeSessionNetworkCallsParams) ([]database.BoundaryLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAIBridgeSessionNetworkCalls", ctx, arg) + ret0, _ := ret[0].([]database.BoundaryLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAIBridgeSessionNetworkCalls indicates an expected call of ListAIBridgeSessionNetworkCalls. +func (mr *MockStoreMockRecorder) ListAIBridgeSessionNetworkCalls(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAIBridgeSessionNetworkCalls", reflect.TypeOf((*MockStore)(nil).ListAIBridgeSessionNetworkCalls), ctx, arg) +} + // ListAIBridgeSessionThreads mocks base method. func (m *MockStore) ListAIBridgeSessionThreads(ctx context.Context, arg database.ListAIBridgeSessionThreadsParams) ([]database.ListAIBridgeSessionThreadsRow, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index db572c970db..ef684c940ce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -1255,6 +1255,26 @@ type sqlcQuerier interface { ListAIBridgeInterceptionsTelemetrySummaries(ctx context.Context, arg ListAIBridgeInterceptionsTelemetrySummariesParams) ([]ListAIBridgeInterceptionsTelemetrySummariesRow, error) ListAIBridgeModelThoughtsByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeModelThought, error) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeModelsParams) ([]string, error) + // Returns the individual Agent Firewall network calls made during an AI + // session, ordered chronologically. All protocols are included, unlike + // GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list + // covers the same events the network_calls summary in ListAIBridgeSessions + // counts. The list is capped at @limit_ rows, so its length equals the summary + // total only for sessions at or below the cap. The summary stays authoritative + // for whole-session totals. + // + // Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each + // interception's boundary logs fall in the open interval (this seq, next + // interception's seq) within the same firewall session. The exclusive lower + // bound drops the interception's own LLM-provider call. next_seq considers all + // interceptions in the firewall session so windows never bleed across AI + // sessions that share one firewall session, and falls back to the maximum + // sequence_number for the last interception so the window stays an + // index-satisfiable range. + // created_at leads because a session can span several firewall sessions, whose + // sequence numbers are independent streams. id breaks remaining ties so the row + // that lands on the limit boundary is stable across identical requests. + ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) // Returns all interceptions belonging to paginated threads within a session. // Threads are paginated by (started_at, thread_id) cursor. ListAIBridgeSessionThreads(ctx context.Context, arg ListAIBridgeSessionThreadsParams) ([]ListAIBridgeSessionThreadsRow, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e385038f618..b22a2fef0d0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2024,6 +2024,85 @@ func (q *sqlQuerier) ListAIBridgeModels(ctx context.Context, arg ListAIBridgeMod return items, nil } +const listAIBridgeSessionNetworkCalls = `-- name: ListAIBridgeSessionNetworkCalls :many +SELECT bl.id, bl.session_id, bl.sequence_number, bl.captured_at, bl.created_at, bl.proto, bl.method, bl.detail, bl.matched_rule, bl.owner_id +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq +WHERE afi.session_id = $1::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF($2::integer, 0), 1000) +` + +type ListAIBridgeSessionNetworkCallsParams struct { + SessionID string `db:"session_id" json:"session_id"` + Limit int32 `db:"limit_" json:"limit_"` +} + +// Returns the individual Agent Firewall network calls made during an AI +// session, ordered chronologically. All protocols are included, unlike +// GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +// covers the same events the network_calls summary in ListAIBridgeSessions +// counts. The list is capped at @limit_ rows, so its length equals the summary +// total only for sessions at or below the cap. The summary stays authoritative +// for whole-session totals. +// +// Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +// interception's boundary logs fall in the open interval (this seq, next +// interception's seq) within the same firewall session. The exclusive lower +// bound drops the interception's own LLM-provider call. next_seq considers all +// interceptions in the firewall session so windows never bleed across AI +// sessions that share one firewall session, and falls back to the maximum +// sequence_number for the last interception so the window stays an +// index-satisfiable range. +// created_at leads because a session can span several firewall sessions, whose +// sequence numbers are independent streams. id breaks remaining ties so the row +// that lands on the limit boundary is stable across identical requests. +func (q *sqlQuerier) ListAIBridgeSessionNetworkCalls(ctx context.Context, arg ListAIBridgeSessionNetworkCallsParams) ([]BoundaryLog, error) { + rows, err := q.db.QueryContext(ctx, listAIBridgeSessionNetworkCalls, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BoundaryLog + for rows.Next() { + var i BoundaryLog + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.SequenceNumber, + &i.CapturedAt, + &i.CreatedAt, + &i.Proto, + &i.Method, + &i.Detail, + &i.MatchedRule, + &i.OwnerID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAIBridgeSessionThreads = `-- name: ListAIBridgeSessionThreads :many WITH paginated_threads AS ( SELECT diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index a99730ba2eb..546fef5d84d 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -618,6 +618,45 @@ FROM domains ORDER BY count DESC, domain ASC LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); +-- name: ListAIBridgeSessionNetworkCalls :many +-- Returns the individual Agent Firewall network calls made during an AI +-- session, ordered chronologically. All protocols are included, unlike +-- GetAIBridgeSessionTopDomains which considers only HTTP egress, so the list +-- covers the same events the network_calls summary in ListAIBridgeSessions +-- counts. The list is capped at @limit_ rows, so its length equals the summary +-- total only for sessions at or below the cap. The summary stays authoritative +-- for whole-session totals. +-- +-- Windowing mirrors that summary and GetAIBridgeSessionTopDomains: each +-- interception's boundary logs fall in the open interval (this seq, next +-- interception's seq) within the same firewall session. The exclusive lower +-- bound drops the interception's own LLM-provider call. next_seq considers all +-- interceptions in the firewall session so windows never bleed across AI +-- sessions that share one firewall session, and falls back to the maximum +-- sequence_number for the last interception so the window stays an +-- index-satisfiable range. +SELECT bl.* +FROM aibridge_interceptions afi +LEFT JOIN LATERAL ( + SELECT COALESCE(MIN(nxt.agent_firewall_sequence_number), 2147483647) AS next_seq + FROM aibridge_interceptions nxt + WHERE nxt.agent_firewall_session_id = afi.agent_firewall_session_id + AND nxt.agent_firewall_sequence_number > afi.agent_firewall_sequence_number +) w ON true +JOIN boundary_logs bl + ON bl.session_id = afi.agent_firewall_session_id + AND bl.sequence_number > afi.agent_firewall_sequence_number + AND bl.sequence_number < w.next_seq +WHERE afi.session_id = @session_id::text + AND afi.ended_at IS NOT NULL + AND afi.agent_firewall_session_id IS NOT NULL + AND afi.agent_firewall_sequence_number IS NOT NULL +-- created_at leads because a session can span several firewall sessions, whose +-- sequence numbers are independent streams. id breaks remaining ties so the row +-- that lands on the limit boundary is stable across identical requests. +ORDER BY bl.created_at ASC, bl.sequence_number ASC, bl.id ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 1000); + -- name: ListAIBridgeSessionThreads :many -- Returns all interceptions belonging to paginated threads within a session. -- Threads are paginated by (started_at, thread_id) cursor. diff --git a/codersdk/aibridge.go b/codersdk/aibridge.go index df400665245..04b54744ca4 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -202,7 +202,13 @@ type AIBridgeSessionThreadsResponse struct { // domains, used to render a "+N more" overflow beyond the listed domains. NetworkTopDomains []AIBridgeSessionNetworkDomain `json:"network_top_domains,omitempty"` NetworkDomainCount int64 `json:"network_domain_count,omitempty"` - Threads []AIBridgeThread `json:"threads"` + // NetworkCallLogs is the chronological list of individual network calls made + // during the session, holding the earliest calls up to a server-side cap. + // NetworkCalls remains authoritative for whole-session totals, so a shorter + // list than NetworkCalls.Total means the list was truncated. Empty when the + // session did not pass through Agent Firewall. + NetworkCallLogs []AgentFirewallLog `json:"network_call_logs,omitempty"` + Threads []AIBridgeThread `json:"threads"` } // AIBridgeSessionThreadsTokenUsage represents aggregated token usage diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 935309c4812..18d1f862e38 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -195,6 +195,20 @@ Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward com "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ], "network_calls": { "blocked": 0, "total": 0 diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index e1d7560090e..be3e275762b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -691,6 +691,20 @@ "models": [ "string" ], + "network_call_logs": [ + { + "allowed": true, + "captured_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "detail": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "matched_rule": "string", + "method": "string", + "proto": "string", + "sequence_number": 0, + "session_id": "1ffd059c-17ea-40a8-8aef-70fd0307db82" + } + ], "network_calls": { "blocked": 0, "total": 0 @@ -785,24 +799,25 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|------------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `metadata` | object | false | | | -| » `[any property]` | any | false | | | -| `models` | array of string | false | | | -| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | -| `network_domain_count` | integer | false | | | -| `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | -| `page_ended_at` | string | false | | | -| `page_started_at` | string | false | | | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | +| Name | Type | Required | Restrictions | Description | +|------------------------|------------------------------------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `metadata` | object | false | | | +| » `[any property]` | any | false | | | +| `models` | array of string | false | | | +| `network_call_logs` | array of [codersdk.AgentFirewallLog](#codersdkagentfirewalllog) | false | | Network call logs is the chronological list of individual network calls made during the session, holding the earliest calls up to a server-side cap. NetworkCalls remains authoritative for whole-session totals, so a shorter list than NetworkCalls.Total means the list was truncated. Empty when the session did not pass through Agent Firewall. | +| `network_calls` | [codersdk.AIBridgeSessionNetworkCallSummary](#codersdkaibridgesessionnetworkcallsummary) | false | | Network calls summarizes the Agent Firewall network calls made during the session. A nil value means the session did not pass through Agent Firewall, so network call monitoring was not active, which the UI surfaces as "Disabled". | +| `network_domain_count` | integer | false | | | +| `network_top_domains` | array of [codersdk.AIBridgeSessionNetworkDomain](#codersdkaibridgesessionnetworkdomain) | false | | Network top domains lists the most contacted destination hosts, ordered by call count descending. NetworkDomainCount is the total number of distinct domains, used to render a "+N more" overflow beyond the listed domains. | +| `page_ended_at` | string | false | | | +| `page_started_at` | string | false | | | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | array of [codersdk.AIBridgeThread](#codersdkaibridgethread) | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionThreadsTokenUsage](#codersdkaibridgesessionthreadstokenusage) | false | | | ## codersdk.AIBridgeSessionThreadsTokenUsage diff --git a/enterprise/coderd/agentfirewall.go b/enterprise/coderd/agentfirewall.go index 928a99e0d99..2773031f25c 100644 --- a/enterprise/coderd/agentfirewall.go +++ b/enterprise/coderd/agentfirewall.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/rbac" @@ -109,30 +110,6 @@ func (api *API) agentFirewallSessionLogs(rw http.ResponseWriter, r *http.Request } httpapi.Write(ctx, rw, http.StatusOK, codersdk.AgentFirewallSessionLogsResponse{ - Results: agentFirewallLogsFromDB(dbLogs), + Results: db2sdk.AgentFirewallLogs(dbLogs), }) } - -// agentFirewallLogsFromDB converts database boundary logs to SDK -// representation. Allowed is derived from MatchedRule being non-NULL. -func agentFirewallLogsFromDB(dbLogs []database.BoundaryLog) []codersdk.AgentFirewallLog { - results := make([]codersdk.AgentFirewallLog, 0, len(dbLogs)) - for _, l := range dbLogs { - bl := codersdk.AgentFirewallLog{ - ID: l.ID, - SessionID: l.SessionID, - SequenceNumber: l.SequenceNumber, - Allowed: l.MatchedRule.Valid, - CreatedAt: l.CreatedAt, - Proto: l.Proto, - Method: l.Method, - Detail: l.Detail, - CapturedAt: &l.CapturedAt, - } - if l.MatchedRule.Valid { - bl.MatchedRule = &l.MatchedRule.String - } - results = append(results, bl) - } - return results -} diff --git a/enterprise/coderd/aibridge.go b/enterprise/coderd/aibridge.go index a428c31b7a0..06e305b7fec 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -47,6 +47,11 @@ const ( // maxAISpendExportPeriod bounds an explicit AI spend export window to at // most 31 days, matching the maximum length of the monthly default period. maxAISpendExportPeriod = 31 * 24 * time.Hour + // aiBridgeSessionNetworkCallsLimit caps the per-session network call list + // returned with session threads. The header count still reflects the full + // summary total, so the UI surfaces truncation when a session exceeds this. + // Sessions past the cap need pagination to see the remainder. + aiBridgeSessionNetworkCallsLimit = 1000 ) // errInvalidCursor is returned when a pagination cursor does not @@ -370,6 +375,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques userPrompts []database.AIBridgeUserPrompt modelThoughts []database.AIBridgeModelThought topDomains []database.GetAIBridgeSessionTopDomainsRow + networkCalls []database.BoundaryLog ) err = api.Database.InTx(func(db database.Store) error { // Validate cursor IDs before querying threads. The SQL @@ -448,6 +454,17 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return xerrors.Errorf("get session top domains: %w", err) } + // List the session's individual network calls. Scoped by session ID + // (not the page) so the list reflects the whole session, consistent + // with the network call summary. + networkCalls, err = db.ListAIBridgeSessionNetworkCalls(ctx, database.ListAIBridgeSessionNetworkCallsParams{ + SessionID: sessionIDParam, + Limit: aiBridgeSessionNetworkCallsLimit, + }) + if err != nil { + return xerrors.Errorf("list session network calls: %w", err) + } + return nil }, &database.TxOptions{ Isolation: sql.LevelRepeatableRead, @@ -469,7 +486,16 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return } - resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts, topDomains) + resp := db2sdk.AIBridgeSessionThreads(db2sdk.AIBridgeSessionThreadsParams{ + Session: session, + Interceptions: threadRows, + TokenUsages: tokenUsages, + ToolUsages: toolUsages, + UserPrompts: userPrompts, + ModelThoughts: modelThoughts, + TopDomains: topDomains, + NetworkCalls: networkCalls, + }) httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 1541174af8d..cba71e26178 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1761,6 +1761,34 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) require.EqualValues(t, 4, res.NetworkTopDomains[0].Count) require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list spans the same window as the summary (all protos, + // seq 0 LLM call excluded), ordered chronologically. Its length and + // blocked count agree with the summary counts. + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 4, 5, 6}, gotSeqs) + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) + + // The blocked npm call (seq 3, index 2) has no matched rule; allowed + // calls do. + npm := res.NetworkCallLogs[2] + require.Equal(t, int32(3), npm.SequenceNumber) + require.Equal(t, "https://registry.npmjs.org/lodash", npm.Detail) + require.False(t, npm.Allowed) + require.Nil(t, npm.MatchedRule) + require.True(t, res.NetworkCallLogs[0].Allowed) + require.NotNil(t, res.NetworkCallLogs[0].MatchedRule) + + // Non-http protocols appear in the list (unlike top domains): seq 5 dns. + require.Equal(t, "dns", res.NetworkCallLogs[4].Proto) }) t.Run("NetworkMultipleInterceptions", func(t *testing.T) { @@ -1815,6 +1843,21 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) require.EqualValues(t, 3, res.NetworkTopDomains[0].Count) require.EqualValues(t, 2, res.NetworkDomainCount) + + // The per-call list spans both windows in chronological order, and its + // length and blocked count agree with the summary (three-way agreement + // across summary, top domains, and list). + gotSeqs := make([]int32, len(res.NetworkCallLogs)) + blocked := 0 + for i, c := range res.NetworkCallLogs { + gotSeqs[i] = c.SequenceNumber + if !c.Allowed { + blocked++ + } + } + require.Equal(t, []int32{1, 2, 3, 6, 7}, gotSeqs) + require.EqualValues(t, res.NetworkCalls.Total, len(res.NetworkCallLogs)) + require.EqualValues(t, res.NetworkCalls.Blocked, blocked) }) t.Run("NetworkSharedFirewallSessionNoBleed", func(t *testing.T) { @@ -1859,12 +1902,18 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { {13, "http", "https://registry.npmjs.org/b3", false}, // B's window (10,+inf), blocked }) - // Session A sees only its own two calls (seqs 1, 2), not B's. + // Session A sees only its own two calls (seqs 1, 2), not B's, in both + // the summary and the per-call list. resA, err := client.AIBridgeGetSessionThreads(ctx, "sess-a", uuid.Nil, uuid.Nil, 0) require.NoError(t, err) require.NotNil(t, resA.NetworkCalls) require.EqualValues(t, 2, resA.NetworkCalls.Total) require.EqualValues(t, 1, resA.NetworkCalls.Blocked) + seqsA := make([]int32, len(resA.NetworkCallLogs)) + for i, c := range resA.NetworkCallLogs { + seqsA[i] = c.SequenceNumber + } + require.Equal(t, []int32{1, 2}, seqsA) // Session B sees only its own three calls (seqs 11, 12, 13), not A's. resB, err := client.AIBridgeGetSessionThreads(ctx, "sess-b", uuid.Nil, uuid.Nil, 0) @@ -1872,6 +1921,59 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.NotNil(t, resB.NetworkCalls) require.EqualValues(t, 3, resB.NetworkCalls.Total) require.EqualValues(t, 1, resB.NetworkCalls.Blocked) + seqsB := make([]int32, len(resB.NetworkCallLogs)) + for i, c := range resB.NetworkCallLogs { + seqsB[i] = c.SequenceNumber + } + require.Equal(t, []int32{11, 12, 13}, seqsB) + }) + + t.Run("NetworkCallsTruncated", func(t *testing.T) { + t.Parallel() + // The per-call list is capped server-side while the summary total + // reflects the whole session. When a session exceeds the cap the list is + // truncated but the summary total stays authoritative. + db, ps := dbtestutil.NewDB(t) + opts := aibridgeOpts(t) + opts.Options.Database = db + opts.Options.Pubsub = ps + client, _, firstUser := coderdenttest.NewWithDatabase(t, opts) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + fw := uuid.New() + + endedAt := now.Add(time.Minute) + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "trunc-net", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, + }, &endedAt) + + // Allowed HTTP calls at seqs 1..total, all in the interception's + // window (0, +inf), seeded past the server-side cap. + const listCap = 1000 + const total = listCap + 5 + seeds := make([]boundaryLogSeed, 0, total) + for seq := int32(1); seq <= total; seq++ { + seeds = append(seeds, boundaryLogSeed{seq, "http", "https://api.github.com/x", true}) + } + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, seeds) + + res, err := client.AIBridgeGetSessionThreads(ctx, "trunc-net", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // The summary reflects every call; the list stops at the cap. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, total, res.NetworkCalls.Total) + require.Len(t, res.NetworkCallLogs, listCap) + // The cap keeps the earliest calls in chronological order. + require.EqualValues(t, 1, res.NetworkCallLogs[0].SequenceNumber) + require.EqualValues(t, listCap, res.NetworkCallLogs[listCap-1].SequenceNumber) }) t.Run("NetworkSummaryDisabled", func(t *testing.T) { @@ -1895,6 +1997,7 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Nil(t, res.NetworkCalls) require.Empty(t, res.NetworkTopDomains) require.EqualValues(t, 0, res.NetworkDomainCount) + require.Empty(t, res.NetworkCallLogs) }) t.Run("ThreadsWithAgenticActions", func(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d6f7e516f40..85652e506e0 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -205,6 +205,14 @@ export interface AIBridgeSessionThreadsResponse { */ readonly network_top_domains?: readonly AIBridgeSessionNetworkDomain[]; readonly network_domain_count?: number; + /** + * NetworkCallLogs is the chronological list of individual network calls made + * during the session, holding the earliest calls up to a server-side cap. + * NetworkCalls remains authoritative for whole-session totals, so a shorter + * list than NetworkCalls.Total means the list was truncated. Empty when the + * session did not pass through Agent Firewall. + */ + readonly network_call_logs?: readonly AgentFirewallLog[]; readonly threads: readonly AIBridgeThread[]; }