diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 388c35f1144..8d42f3ea919 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15561,7 +15561,7 @@ const docTemplate = `{ } }, "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\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests 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": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" @@ -15597,6 +15597,17 @@ const docTemplate = `{ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -15623,6 +15634,24 @@ const docTemplate = `{ "type": "string" } }, + "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": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, + "network_domain_count": { + "type": "integer" + }, + "network_top_domains": { + "description": "NetworkTopDomains lists the most contacted destination hosts, ordered by\ncall count descending. NetworkDomainCount is the total number of distinct\ndomains, used to render a \"+N more\" overflow beyond the listed domains.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkDomain" + } + }, "page_ended_at": { "type": "string", "format": "date-time" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4553c63fc92..2baad8e59b3 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -13855,7 +13855,7 @@ } }, "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\".", + "description": "NetworkCalls summarizes the Agent Firewall network requests 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": [ { "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" @@ -13891,6 +13891,17 @@ } } }, + "codersdk.AIBridgeSessionNetworkDomain": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "domain": { + "type": "string" + } + } + }, "codersdk.AIBridgeSessionThreadsResponse": { "type": "object", "properties": { @@ -13917,6 +13928,24 @@ "type": "string" } }, + "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": [ + { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkCallSummary" + } + ] + }, + "network_domain_count": { + "type": "integer" + }, + "network_top_domains": { + "description": "NetworkTopDomains lists the most contacted destination hosts, ordered by\ncall count descending. NetworkDomainCount is the total number of distinct\ndomains, used to render a \"+N more\" overflow beyond the listed domains.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.AIBridgeSessionNetworkDomain" + } + }, "page_ended_at": { "type": "string", "format": "date-time" diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 234b56739ea..75deb23053d 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1153,6 +1153,7 @@ func AIBridgeSessionThreads( toolUsages []database.AIBridgeToolUsage, userPrompts []database.AIBridgeUserPrompt, modelThoughts []database.AIBridgeModelThought, + topDomains []database.GetAIBridgeSessionTopDomainsRow, ) codersdk.AIBridgeSessionThreadsResponse { // Index subresources by interception ID. tokensByInterception := make(map[uuid.UUID][]database.AIBridgeTokenUsage, len(interceptions)) @@ -1243,6 +1244,24 @@ func AIBridgeSessionThreads( if !session.EndedAt.IsZero() { resp.EndedAt = &session.EndedAt } + // NetworkCalls is only meaningful when the session passed through Agent + // Firewall. When it did not, leave it nil so the UI renders "Disabled" + // rather than a misleading zero count. + if session.FirewallActive { + resp.NetworkCalls = &codersdk.AIBridgeSessionNetworkCallSummary{ + Total: session.NetworkCallsTotal, + Blocked: session.NetworkCallsBlocked, + } + } + for _, d := range topDomains { + resp.NetworkTopDomains = append(resp.NetworkTopDomains, codersdk.AIBridgeSessionNetworkDomain{ + Domain: d.Domain, + Count: d.Count, + }) + // TotalDomains is the same on every row (a window aggregate); take it + // from the last row processed. + resp.NetworkDomainCount = d.TotalDomains + } return resp } diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3b51ccceb65..30a6389a0b7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2811,6 +2811,13 @@ func (q *querier) GetAIBridgeInterceptions(ctx context.Context) ([]database.AIBr return fetchWithPostFilter(q.auth, policy.ActionRead, fetch)(ctx, nil) } +func (q *querier) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceAibridgeInterception); err != nil { + return nil, err + } + return q.db.GetAIBridgeSessionTopDomains(ctx, arg) +} + func (q *querier) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { // All aibridge_token_usages records belong to the initiator of their associated interception. if err := q.authorizeAIBridgeInterceptionAction(ctx, policy.ActionRead, interceptionID); err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 40bf232df43..503c80465ae 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -6962,6 +6962,12 @@ func (s *MethodTestSuite) TestAIBridge() { check.Args(params, emptyPreparedAuthorized{}).Asserts() })) + s.Run("GetAIBridgeSessionTopDomains", s.Mocked(func(db *dbmock.MockStore, faker *gofakeit.Faker, check *expects) { + params := database.GetAIBridgeSessionTopDomainsParams{SessionID: "sess", Limit: 5} + db.EXPECT().GetAIBridgeSessionTopDomains(gomock.Any(), params).Return([]database.GetAIBridgeSessionTopDomainsRow{}, nil).AnyTimes() + check.Args(params).Asserts(rbac.ResourceAibridgeInterception, policy.ActionRead).Returns([]database.GetAIBridgeSessionTopDomainsRow{}) + })) + 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 a47adc4aae7..33d5364aa8e 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -1105,6 +1105,14 @@ func (m queryMetricsStore) GetAIBridgeInterceptions(ctx context.Context) ([]data return r0, r1 } +func (m queryMetricsStore) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + start := time.Now() + r0, r1 := m.s.GetAIBridgeSessionTopDomains(ctx, arg) + m.queryLatencies.WithLabelValues("GetAIBridgeSessionTopDomains").Observe(time.Since(start).Seconds()) + m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetAIBridgeSessionTopDomains").Inc() + return r0, r1 +} + func (m queryMetricsStore) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { start := time.Now() r0, r1 := m.s.GetAIBridgeTokenUsagesByInterceptionID(ctx, interceptionID) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c63d297527b..c7b824b24d9 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1903,6 +1903,21 @@ func (mr *MockStoreMockRecorder) GetAIBridgeInterceptions(ctx any) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeInterceptions", reflect.TypeOf((*MockStore)(nil).GetAIBridgeInterceptions), ctx) } +// GetAIBridgeSessionTopDomains mocks base method. +func (m *MockStore) GetAIBridgeSessionTopDomains(ctx context.Context, arg database.GetAIBridgeSessionTopDomainsParams) ([]database.GetAIBridgeSessionTopDomainsRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAIBridgeSessionTopDomains", ctx, arg) + ret0, _ := ret[0].([]database.GetAIBridgeSessionTopDomainsRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAIBridgeSessionTopDomains indicates an expected call of GetAIBridgeSessionTopDomains. +func (mr *MockStoreMockRecorder) GetAIBridgeSessionTopDomains(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAIBridgeSessionTopDomains", reflect.TypeOf((*MockStore)(nil).GetAIBridgeSessionTopDomains), ctx, arg) +} + // GetAIBridgeTokenUsagesByInterceptionID mocks base method. func (m *MockStore) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]database.AIBridgeTokenUsage, error) { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7ed0f7bc02b..0ab566b7d1b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -302,6 +302,21 @@ type sqlcQuerier interface { // the root), we return its own ID as the root. GetAIBridgeInterceptionLineageByToolCallID(ctx context.Context, toolCallID string) (GetAIBridgeInterceptionLineageByToolCallIDRow, error) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeInterception, error) + // Returns the most contacted destination hosts for an AI session, ordered by + // call count descending and limited to the top @limit_ rows. total_domains is + // the number of distinct domains across the whole session, used to render a + // "+N more" overflow beyond the returned rows. Only HTTP egress is considered; + // dns/git/fs boundary logs do not carry a domain in the same shape. + // + // Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: + // 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. + GetAIBridgeSessionTopDomains(ctx context.Context, arg GetAIBridgeSessionTopDomainsParams) ([]GetAIBridgeSessionTopDomainsRow, error) GetAIBridgeTokenUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeTokenUsage, error) GetAIBridgeToolUsagesByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeToolUsage, error) GetAIBridgeUserPromptsByInterceptionID(ctx context.Context, interceptionID uuid.UUID) ([]AIBridgeUserPrompt, error) @@ -1243,6 +1258,12 @@ type sqlcQuerier interface { // Pagination-first strategy: identify the page of sessions cheaply via a // single GROUP BY scan, then do expensive lateral joins (tokens, prompts, // first-interception metadata) only for the ~page-size result set. + // The last interception in a session has no next row, so next_seq uses + // the largest sequence_number instead of NULL. The lookup stays a plain + // range, so the (session_id, sequence_number) index answers it alone. + // With NULL and an OR check, the index cannot bound the range: each + // interception reads every log to the end of the session and throws + // most of them away. ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) ListAIBridgeTokenUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeTokenUsage, error) ListAIBridgeToolUsagesByInterceptionIDs(ctx context.Context, interceptionIds []uuid.UUID) ([]AIBridgeToolUsage, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e9df5bb7977..7ba20c7fc06 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1272,6 +1272,101 @@ func (q *sqlQuerier) GetAIBridgeInterceptions(ctx context.Context) ([]AIBridgeIn return items, nil } +const getAIBridgeSessionTopDomains = `-- name: GetAIBridgeSessionTopDomains :many +WITH session_boundary_logs AS ( + SELECT bl.detail + 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 = $2::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 + AND bl.proto = 'http' +), +extracted AS ( + -- Strip an optional scheme, then keep the host up to the first port, path, + -- query, or fragment delimiter. This assumes HTTP egress detail is a plain + -- scheme+host(+port) URL: it does not handle userinfo (user@host, which + -- would be captured into the host) or IPv6 literal hosts ([::1], where the + -- leading '[' is captured and the ':' terminates early). Boundary HTTP logs + -- do not currently emit those forms; revisit this extraction if they do. + SELECT substring(detail from '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/:?#]+)') AS domain + FROM session_boundary_logs +), +domains AS ( + SELECT domain, COUNT(*)::bigint AS count + FROM extracted + WHERE domain IS NOT NULL AND domain != '' + GROUP BY domain +) +SELECT + -- COALESCE keeps sqlc from typing the grouped column as nullable; the + -- domains CTE already filters out NULL/empty hosts. + COALESCE(domain, '')::text AS domain, + count, + COUNT(*) OVER ()::bigint AS total_domains +FROM domains +ORDER BY count DESC, domain ASC +LIMIT COALESCE(NULLIF($1::integer, 0), 5) +` + +type GetAIBridgeSessionTopDomainsParams struct { + Limit int32 `db:"limit_" json:"limit_"` + SessionID string `db:"session_id" json:"session_id"` +} + +type GetAIBridgeSessionTopDomainsRow struct { + Domain string `db:"domain" json:"domain"` + Count int64 `db:"count" json:"count"` + TotalDomains int64 `db:"total_domains" json:"total_domains"` +} + +// Returns the most contacted destination hosts for an AI session, ordered by +// call count descending and limited to the top @limit_ rows. total_domains is +// the number of distinct domains across the whole session, used to render a +// "+N more" overflow beyond the returned rows. Only HTTP egress is considered; +// dns/git/fs boundary logs do not carry a domain in the same shape. +// +// Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: +// 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. +func (q *sqlQuerier) GetAIBridgeSessionTopDomains(ctx context.Context, arg GetAIBridgeSessionTopDomainsParams) ([]GetAIBridgeSessionTopDomainsRow, error) { + rows, err := q.db.QueryContext(ctx, getAIBridgeSessionTopDomains, arg.Limit, arg.SessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAIBridgeSessionTopDomainsRow + for rows.Next() { + var i GetAIBridgeSessionTopDomainsRow + if err := rows.Scan(&i.Domain, &i.Count, &i.TotalDomains); 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 getAIBridgeTokenUsagesByInterceptionID = `-- name: GetAIBridgeTokenUsagesByInterceptionID :many SELECT id, interception_id, provider_response_id, input_tokens, output_tokens, metadata, created_at, cache_read_input_tokens, cache_write_input_tokens, effective_group_id, input_price_micros, output_price_micros, cache_read_price_micros, cache_write_price_micros, cost_micros @@ -2164,12 +2259,13 @@ LEFT JOIN LATERAL ( -- (logged at exactly its sequence number), leaving the agent's other -- egress. next_seq considers all interceptions in the firewall session so -- windows never bleed across AI sessions that share one firewall session. + -- SELECT COUNT(*)::bigint AS total, COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + 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 @@ -2177,7 +2273,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.id = ANY(sr.interception_ids) AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL @@ -2232,6 +2328,12 @@ type ListAIBridgeSessionsRow struct { // Pagination-first strategy: identify the page of sessions cheaply via a // single GROUP BY scan, then do expensive lateral joins (tokens, prompts, // first-interception metadata) only for the ~page-size result set. +// The last interception in a session has no next row, so next_seq uses +// the largest sequence_number instead of NULL. The lookup stays a plain +// range, so the (session_id, sequence_number) index answers it alone. +// With NULL and an OR check, the index cannot bound the range: each +// interception reads every log to the end of the session and throws +// most of them away. func (q *sqlQuerier) ListAIBridgeSessions(ctx context.Context, arg ListAIBridgeSessionsParams) ([]ListAIBridgeSessionsRow, error) { rows, err := q.db.QueryContext(ctx, listAIBridgeSessions, arg.AfterSessionID, diff --git a/coderd/database/queries/aibridge.sql b/coderd/database/queries/aibridge.sql index 63635b6ae22..977d200151b 100644 --- a/coderd/database/queries/aibridge.sql +++ b/coderd/database/queries/aibridge.sql @@ -528,12 +528,19 @@ LEFT JOIN LATERAL ( -- (logged at exactly its sequence number), leaving the agent's other -- egress. next_seq considers all interceptions in the firewall session so -- windows never bleed across AI sessions that share one firewall session. + -- +-- The last interception in a session has no next row, so next_seq uses +-- the largest sequence_number instead of NULL. The lookup stays a plain +-- range, so the (session_id, sequence_number) index answers it alone. +-- With NULL and an OR check, the index cannot bound the range: each +-- interception reads every log to the end of the session and throws +-- most of them away. SELECT COUNT(*)::bigint AS total, COUNT(*) FILTER (WHERE bl.matched_rule IS NULL)::bigint AS blocked FROM aibridge_interceptions afi LEFT JOIN LATERAL ( - SELECT MIN(nxt.agent_firewall_sequence_number) AS next_seq + 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 @@ -541,7 +548,7 @@ LEFT JOIN LATERAL ( JOIN boundary_logs bl ON bl.session_id = afi.agent_firewall_session_id AND bl.sequence_number > afi.agent_firewall_sequence_number - AND (w.next_seq IS NULL OR bl.sequence_number < w.next_seq) + AND bl.sequence_number < w.next_seq WHERE afi.id = ANY(sr.interception_ids) AND afi.agent_firewall_session_id IS NOT NULL AND afi.agent_firewall_sequence_number IS NOT NULL @@ -551,6 +558,66 @@ ORDER BY sp.session_id DESC ; +-- name: GetAIBridgeSessionTopDomains :many +-- Returns the most contacted destination hosts for an AI session, ordered by +-- call count descending and limited to the top @limit_ rows. total_domains is +-- the number of distinct domains across the whole session, used to render a +-- "+N more" overflow beyond the returned rows. Only HTTP egress is considered; +-- dns/git/fs boundary logs do not carry a domain in the same shape. +-- +-- Windowing mirrors the network_calls aggregation in ListAIBridgeSessions: +-- 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. +WITH session_boundary_logs AS ( + SELECT bl.detail + 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 + AND bl.proto = 'http' +), +extracted AS ( + -- Strip an optional scheme, then keep the host up to the first port, path, + -- query, or fragment delimiter. This assumes HTTP egress detail is a plain + -- scheme+host(+port) URL: it does not handle userinfo (user@host, which + -- would be captured into the host) or IPv6 literal hosts ([::1], where the + -- leading '[' is captured and the ':' terminates early). Boundary HTTP logs + -- do not currently emit those forms; revisit this extraction if they do. + SELECT substring(detail from '^(?:[A-Za-z][A-Za-z0-9+.-]*://)?([^/:?#]+)') AS domain + FROM session_boundary_logs +), +domains AS ( + SELECT domain, COUNT(*)::bigint AS count + FROM extracted + WHERE domain IS NOT NULL AND domain != '' + GROUP BY domain +) +SELECT + -- COALESCE keeps sqlc from typing the grouped column as nullable; the + -- domains CTE already filters out NULL/empty hosts. + COALESCE(domain, '')::text AS domain, + count, + COUNT(*) OVER ()::bigint AS total_domains +FROM domains +ORDER BY count DESC, domain ASC +LIMIT COALESCE(NULLIF(@limit_::integer, 0), 5); + -- 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 66f754bae61..df400665245 100644 --- a/codersdk/aibridge.go +++ b/codersdk/aibridge.go @@ -141,7 +141,7 @@ type AIBridgeSession struct { EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` Threads int64 `json:"threads"` TokenUsageSummary AIBridgeSessionTokenUsageSummary `json:"token_usage_summary"` - // NetworkCalls summarizes the Agent Firewall network calls made during the + // NetworkCalls summarizes the Agent Firewall network requests 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". @@ -165,6 +165,13 @@ type AIBridgeSessionNetworkCallSummary struct { Blocked int64 `json:"blocked"` } +// AIBridgeSessionNetworkDomain is one destination host contacted during a +// session, with the number of network calls made to it. +type AIBridgeSessionNetworkDomain struct { + Domain string `json:"domain"` + Count int64 `json:"count"` +} + type AIBridgeListSessionsResponse struct { Count int64 `json:"count"` Sessions []AIBridgeSession `json:"sessions"` @@ -185,7 +192,17 @@ type AIBridgeSessionThreadsResponse struct { StartedAt time.Time `json:"started_at" format:"date-time"` EndedAt *time.Time `json:"ended_at,omitempty" format:"date-time"` TokenUsageSummary AIBridgeSessionThreadsTokenUsage `json:"token_usage_summary"` - Threads []AIBridgeThread `json:"threads"` + // NetworkCalls 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". + NetworkCalls *AIBridgeSessionNetworkCallSummary `json:"network_calls,omitempty"` + // NetworkTopDomains 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. + NetworkTopDomains []AIBridgeSessionNetworkDomain `json:"network_top_domains,omitempty"` + NetworkDomainCount int64 `json:"network_domain_count,omitempty"` + Threads []AIBridgeThread `json:"threads"` } // AIBridgeSessionThreadsTokenUsage represents aggregated token usage diff --git a/docs/ai-coder/ai-gateway/audit.md b/docs/ai-coder/ai-gateway/audit.md index 6cc8ef955fb..b43f8984413 100644 --- a/docs/ai-coder/ai-gateway/audit.md +++ b/docs/ai-coder/ai-gateway/audit.md @@ -48,12 +48,12 @@ not just what was called. The sessions page (`http:///ai-gateway/sessions`) lists all sessions in reverse-chronological order. Each row shows the last prompt, initiator, provider, -client, token usage, network calls, thread count, and timestamp. +client, token usage, network requests, thread count, and timestamp. -The network calls column reports the total and blocked -[Agent Firewall](../agent-firewall/index.md) calls for the session. It shows -`No activity` when the session made no calls, and `Disabled` when the session -did not pass through Agent Firewall, so no network calls were monitored. +The **Network Requests** column reports the total and blocked +[Agent Firewall](../agent-firewall/index.md) requests for the session. It shows +`No activity` when the session made no requests, and `Disabled` when the session +did not pass through Agent Firewall, so no network requests were monitored. Select one to view its full details. @@ -66,6 +66,20 @@ Click into a session to see a chronological causal chain of events. Within a thread, each step shows token usage, tool call details (including arguments and MCP server URLs), duration, and any errors or warnings. +The **Session summary** card beside the timeline reports the session's +[Agent Firewall](../agent-firewall/index.md) activity: + +- **Network requests** is the total number of requests the session made. It + shows `Disabled` when the session did not pass through Agent Firewall, so + monitoring was not active, and `No activity` when the session made no + requests. +- **Blocked network requests** is the subset of those requests that the + allow-list denied. This row appears only when the session made at least one + request, and is highlighted when any were blocked. +- **Top domains** names the destination host the session contacted most, and + appears whenever at least one domain was recorded. When the session contacted + more than one distinct domain, a `+N more` count follows. + ![Session detail](../../images/aibridge/session_detail.png) ## Conducting a forensic audit diff --git a/docs/reference/api/aigateway.md b/docs/reference/api/aigateway.md index 3d8ed690729..935309c4812 100644 --- a/docs/reference/api/aigateway.md +++ b/docs/reference/api/aigateway.md @@ -195,6 +195,17 @@ Alias: also available at /api/v2/aibridge/sessions/{session_id} for backward com "models": [ "string" ], + "network_calls": { + "blocked": 0, + "total": 0 + }, + "network_domain_count": 0, + "network_top_domains": [ + { + "count": 0, + "domain": "string" + } + ], "page_ended_at": "2019-08-24T14:15:22Z", "page_started_at": "2019-08-24T14:15:22Z", "providers": [ diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index f8295c7d1ba..fba380d013b 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -622,22 +622,22 @@ ### Properties -| Name | Type | Required | Restrictions | Description | -|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client` | string | false | | | -| `ended_at` | string | false | | | -| `id` | string | false | | | -| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | -| `last_active_at` | string | false | | | -| `last_prompt` | string | 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". | -| `providers` | array of string | false | | | -| `started_at` | string | false | | | -| `threads` | integer | false | | | -| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | +| Name | Type | Required | Restrictions | Description | +|-----------------------|------------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client` | string | false | | | +| `ended_at` | string | false | | | +| `id` | string | false | | | +| `initiator` | [codersdk.MinimalUser](#codersdkminimaluser) | false | | | +| `last_active_at` | string | false | | | +| `last_prompt` | string | 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 requests 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". | +| `providers` | array of string | false | | | +| `started_at` | string | false | | | +| `threads` | integer | false | | | +| `token_usage_summary` | [codersdk.AIBridgeSessionTokenUsageSummary](#codersdkaibridgesessiontokenusagesummary) | false | | | ## codersdk.AIBridgeSessionNetworkCallSummary @@ -655,6 +655,22 @@ | `blocked` | integer | false | | | | `total` | integer | false | | | +## codersdk.AIBridgeSessionNetworkDomain + +```json +{ + "count": 0, + "domain": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|----------|---------|----------|--------------|-------------| +| `count` | integer | false | | | +| `domain` | string | false | | | + ## codersdk.AIBridgeSessionThreadsResponse ```json @@ -675,6 +691,17 @@ "models": [ "string" ], + "network_calls": { + "blocked": 0, + "total": 0 + }, + "network_domain_count": 0, + "network_top_domains": [ + { + "count": 0, + "domain": "string" + } + ], "page_ended_at": "2019-08-24T14:15:22Z", "page_started_at": "2019-08-24T14:15:22Z", "providers": [ @@ -758,21 +785,24 @@ ### 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 | | | -| `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_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/aibridge.go b/enterprise/coderd/aibridge.go index 884bd23afdb..a428c31b7a0 100644 --- a/enterprise/coderd/aibridge.go +++ b/enterprise/coderd/aibridge.go @@ -369,6 +369,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques toolUsages []database.AIBridgeToolUsage userPrompts []database.AIBridgeUserPrompt modelThoughts []database.AIBridgeModelThought + topDomains []database.GetAIBridgeSessionTopDomainsRow ) err = api.Database.InTx(func(db database.Store) error { // Validate cursor IDs before querying threads. The SQL @@ -435,6 +436,18 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return xerrors.Errorf("list model thoughts: %w", err) } + // Aggregate the session's top network destination. Scoped by session + // ID (not the page) so the summary reflects the whole session. The + // summary card renders only the single most-contacted domain plus a + // "+N more" count derived from NetworkDomainCount, so we fetch one row. + topDomains, err = db.GetAIBridgeSessionTopDomains(ctx, database.GetAIBridgeSessionTopDomainsParams{ + SessionID: sessionIDParam, + Limit: 1, + }) + if err != nil { + return xerrors.Errorf("get session top domains: %w", err) + } + return nil }, &database.TxOptions{ Isolation: sql.LevelRepeatableRead, @@ -456,7 +469,7 @@ func (api *API) aiBridgeGetSessionThreads(rw http.ResponseWriter, r *http.Reques return } - resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts) + resp := db2sdk.AIBridgeSessionThreads(session, threadRows, tokenUsages, toolUsages, userPrompts, modelThoughts, topDomains) httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 00c7b4dde7a..1541174af8d 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1562,6 +1562,39 @@ func TestAIBridgeConcurrencyLimiting(t *testing.T) { } } +type boundaryLogSeed struct { + seq int32 + proto string + detail string + allowed bool +} + +// seedBoundaryLogs writes boundary logs for a firewall session via the raw +// store. A non-empty matched_rule marks a call allowed; a blocked call stores a +// NULL rule. No RBAC role grants boundary_log:create, so tests seed directly. +func seedBoundaryLogs(t *testing.T, db database.Store, fw, ownerID uuid.UUID, at time.Time, seeds []boundaryLogSeed) { + t.Helper() + logs := make([]database.BoundaryLog, 0, len(seeds)) + for _, s := range seeds { + rule := "" + if s.allowed { + rule = "allow " + s.detail + } + logs = append(logs, database.BoundaryLog{ + SessionID: fw, + OwnerID: uuid.NullUUID{UUID: ownerID, Valid: true}, + SequenceNumber: s.seq, + CapturedAt: at, + CreatedAt: at, + Proto: s.proto, + Method: "GET", + Detail: s.detail, + MatchedRule: sql.NullString{String: rule, Valid: rule != ""}, + }) + } + dbgen.BoundaryLogs(t, db, logs) +} + func TestAIBridgeGetSessionThreads(t *testing.T) { t.Parallel() @@ -1672,6 +1705,198 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.Nil(t, res.Threads[1].AgentFirewallSequenceNumber) }) + t.Run("NetworkSummary", func(t *testing.T) { + t.Parallel() + // Use the raw store so boundary logs can be seeded directly. No RBAC + // role grants boundary_log:create; they are written by the agent path. + 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() + + // One interception marked at firewall seq 0, so its window is (0, +inf) + // and the LLM-provider call logged at seq 0 is excluded. + 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: "net-session", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: 0, Valid: true}, + }, &endedAt) + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm", true}, // LLM call, excluded + {1, "http", "https://api.github.com/repos/coder", true}, // github egress + {2, "http", "https://api.github.com/repos/other", true}, // github egress + {3, "http", "https://registry.npmjs.org/lodash", false}, // npm egress, blocked + {4, "http", "https://api.github.com/repos/more", true}, // github egress + {5, "dns", "example.com", true}, // non-http, ignored by top domains + {6, "http", "https://api.github.com:8080/repos", true}, // port-suffixed; host stripped to api.github.com + }) + + res, err := client.AIBridgeGetSessionThreads(ctx, "net-session", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // total counts seq 1-6 (LLM call at seq 0 excluded); one blocked. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, 6, res.NetworkCalls.Total) + require.EqualValues(t, 1, res.NetworkCalls.Blocked) + + // Top domains covers HTTP egress only and is capped at one row (the + // summary card renders a single domain). github wins with 4 HTTP calls: + // seqs 1, 2, 4, and the port-suffixed seq 6 whose host strips to + // api.github.com (proving the port is not treated as a separate host). + // The dns log (seq 5) is excluded from domains. NetworkDomainCount is a + // window aggregate independent of the row cap, so it still reports the + // two distinct HTTP domains (github, npm). + require.Len(t, res.NetworkTopDomains, 1) + require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) + require.EqualValues(t, 4, res.NetworkTopDomains[0].Count) + require.EqualValues(t, 2, res.NetworkDomainCount) + }) + + t.Run("NetworkMultipleInterceptions", func(t *testing.T) { + t.Parallel() + // Two interceptions in the same firewall session must produce two + // consecutive, non-overlapping windows: (0, 5) for the first and + // (5, +inf) for the second. Each interception's own LLM call (logged at + // its own sequence) is excluded by the exclusive lower bound. + 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() + + for _, seq := range []int32{0, 5} { + 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: "multi-net", Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: seq, Valid: true}, + }, &endedAt) + } + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm", true}, // interception 1 LLM call, excluded + {1, "http", "https://api.github.com/a", true}, // window (0,5) + {2, "http", "https://api.github.com/b", true}, // window (0,5) + {3, "http", "https://registry.npmjs.org/x", false}, // window (0,5), blocked + {5, "http", "https://api.github.com/llm2", true}, // interception 2 LLM call, excluded + {6, "http", "https://api.github.com/c", true}, // window (5,+inf) + {7, "http", "https://registry.npmjs.org/y", false}, // window (5,+inf), blocked + }) + + res, err := client.AIBridgeGetSessionThreads(ctx, "multi-net", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + + // Both windows contribute: seqs 1,2,3,6,7. The two LLM calls (0, 5) are + // excluded. Blocked = seqs 3 and 7. + require.NotNil(t, res.NetworkCalls) + require.EqualValues(t, 5, res.NetworkCalls.Total) + require.EqualValues(t, 2, res.NetworkCalls.Blocked) + // github: seqs 1,2,6 = 3; npm: seqs 3,7 = 2. Two distinct domains. + require.Len(t, res.NetworkTopDomains, 1) + require.Equal(t, "api.github.com", res.NetworkTopDomains[0].Domain) + require.EqualValues(t, 3, res.NetworkTopDomains[0].Count) + require.EqualValues(t, 2, res.NetworkDomainCount) + }) + + t.Run("NetworkSharedFirewallSessionNoBleed", func(t *testing.T) { + t.Parallel() + // Two AI sessions share one firewall session. next_seq considers every + // interception in the firewall session, so session A's window is bounded + // by session B's interception and B's calls never bleed into A's counts. + 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() + + // Session A anchored at firewall seq 0; session B at seq 10. + for _, s := range []struct { + session string + seq int32 + }{{"sess-a", 0}, {"sess-b", 10}} { + 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: s.session, Valid: true}, + AgentFirewallSessionID: uuid.NullUUID{UUID: fw, Valid: true}, + AgentFirewallSequenceNumber: sql.NullInt32{Int32: s.seq, Valid: true}, + }, &endedAt) + } + + seedBoundaryLogs(t, db, fw, firstUser.UserID, now, []boundaryLogSeed{ + {0, "http", "https://api.github.com/llm-a", true}, // A's LLM call, excluded + {1, "http", "https://api.github.com/a1", true}, // A's window (0,10) + {2, "http", "https://registry.npmjs.org/a2", false}, // A's window (0,10), blocked + {10, "http", "https://api.github.com/llm-b", true}, // B's LLM call, excluded + {11, "http", "https://api.github.com/b1", true}, // B's window (10,+inf) + {12, "http", "https://api.github.com/b2", true}, // B's window (10,+inf) + {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. + 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) + + // 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) + require.NoError(t, err) + require.NotNil(t, resB.NetworkCalls) + require.EqualValues(t, 3, resB.NetworkCalls.Total) + require.EqualValues(t, 1, resB.NetworkCalls.Blocked) + }) + + t.Run("NetworkSummaryDisabled", func(t *testing.T) { + t.Parallel() + client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) + ctx := testutil.Context(t, testutil.WaitLong) + + now := dbtime.Now() + endedAt := now.Add(time.Minute) + // No firewall correlation: network monitoring was not active. + dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ + InitiatorID: firstUser.UserID, + Provider: "anthropic", + Model: "claude-sonnet-4-20250514", + StartedAt: now, + ClientSessionID: sql.NullString{String: "no-fw-session", Valid: true}, + }, &endedAt) + + res, err := client.AIBridgeGetSessionThreads(ctx, "no-fw-session", uuid.Nil, uuid.Nil, 0) + require.NoError(t, err) + require.Nil(t, res.NetworkCalls) + require.Empty(t, res.NetworkTopDomains) + require.EqualValues(t, 0, res.NetworkDomainCount) + }) + t.Run("ThreadsWithAgenticActions", func(t *testing.T) { t.Parallel() client, db, firstUser := coderdenttest.NewWithDatabase(t, aibridgeOpts(t)) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5dadf27b2b2..cde54a0d58b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -141,7 +141,7 @@ export interface AIBridgeSession { readonly threads: number; readonly token_usage_summary: AIBridgeSessionTokenUsageSummary; /** - * NetworkCalls summarizes the Agent Firewall network calls made during the + * NetworkCalls summarizes the Agent Firewall network requests 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". @@ -162,6 +162,16 @@ export interface AIBridgeSessionNetworkCallSummary { readonly blocked: number; } +// From codersdk/aibridge.go +/** + * AIBridgeSessionNetworkDomain is one destination host contacted during a + * session, with the number of network calls made to it. + */ +export interface AIBridgeSessionNetworkDomain { + readonly domain: string; + readonly count: number; +} + // From codersdk/aibridge.go /** * AIBridgeSessionThreadsResponse is the response for GET @@ -181,6 +191,20 @@ export interface AIBridgeSessionThreadsResponse { readonly started_at: string; readonly ended_at?: string; readonly token_usage_summary: AIBridgeSessionThreadsTokenUsage; + /** + * NetworkCalls 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". + */ + readonly network_calls?: AIBridgeSessionNetworkCallSummary; + /** + * NetworkTopDomains 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. + */ + readonly network_top_domains?: readonly AIBridgeSessionNetworkDomain[]; + readonly network_domain_count?: number; readonly threads: readonly AIBridgeThread[]; } diff --git a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx index bdc65d47e89..21299d54c36 100644 --- a/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx +++ b/site/src/pages/AIBridgePage/ListSessionsPage/ListSessionsPageView.tsx @@ -86,7 +86,7 @@ export const ListSessionsPageView: FC = ({ Provider Client In/Out Tokens - Network Calls + Network Requests Threads diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx index 62d9af8f4a8..f391d020428 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.stories.tsx @@ -49,7 +49,7 @@ export const TotalAndBlockedKeyboard: Story = { await userEvent.tab(); await waitFor(() => { const tooltip = screen.getByRole("tooltip"); - expect(tooltip).toHaveTextContent("Total calls"); + expect(tooltip).toHaveTextContent("Total requests"); expect(tooltip).toHaveTextContent("Blocked"); }); }, @@ -66,7 +66,7 @@ export const DisabledKeyboard: Story = { await userEvent.keyboard("{Enter}"); await waitFor(() => expect(screen.getByRole("dialog")).toHaveTextContent( - "Network call monitoring was not active for this session.", + "Network request monitoring was not active for this session.", ), ); }, diff --git a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx index a4e621a94d0..2da53ba97cd 100644 --- a/site/src/pages/AIBridgePage/NetworkCallBadges.tsx +++ b/site/src/pages/AIBridgePage/NetworkCallBadges.tsx @@ -2,36 +2,30 @@ import { BanIcon } from "lucide-react"; import type { FC } from "react"; import type { AIBridgeSessionNetworkCallSummary } from "#/api/typesGenerated"; import { Badge } from "#/components/Badge/Badge"; -import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "#/components/Tooltip/Tooltip"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "./NetworkRequestStates"; interface NetworkCallBadgesProps { - // summary is undefined when network call monitoring was not active for the - // session, which renders as "Disabled". + // summary is undefined when network request monitoring was not active for + // the session, which renders as "Disabled". summary: AIBridgeSessionNetworkCallSummary | undefined; } export const NetworkCallBadges: FC = ({ summary }) => { if (!summary) { - return ( - - Disabled - - - ); + return ; } if (summary.total === 0) { - return ( - - No activity - - ); + return ; } return ( @@ -63,7 +57,7 @@ export const NetworkCallBadges: FC = ({ summary }) => { >
- Total calls + Total requests {summary.total.toLocaleString("en-US")}
diff --git a/site/src/pages/AIBridgePage/NetworkRequestStates.tsx b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx new file mode 100644 index 00000000000..2e436003e82 --- /dev/null +++ b/site/src/pages/AIBridgePage/NetworkRequestStates.tsx @@ -0,0 +1,17 @@ +import type { FC } from "react"; +import { InfoTooltip } from "#/components/InfoTooltip/InfoTooltip"; + +// Shared by the sessions list badges and the session detail summary card, which +// render the same two non-numeric states for a session's network requests but +// differ in how they present a live count. + +export const NetworkMonitoringDisabled: FC = () => ( + + Disabled + + +); + +export const NetworkNoActivity: FC = () => ( + No activity +); diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx index 10bc86d21b3..b3e851ee4d3 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, screen, userEvent, waitFor } from "storybook/test"; import { MockSession } from "#/testHelpers/entities"; import { SessionSummaryTable } from "./SessionSummaryTable"; @@ -56,3 +57,83 @@ export const LargeTokenCounts: Story = { outputTokens: 32_000, }, }; + +// Session did not pass through Agent Firewall: monitoring was not active. +export const NetworkDisabled: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Disabled")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + await expect(canvas.queryByText("Top domains")).toBeNull(); + }, +}; + +// Tabbing to the disabled indicator's info button and pressing Enter reveals +// the reason without a mouse. +export const NetworkDisabledKeyboard: Story = { + args: { + ...Default.args, + networkCalls: undefined, + }, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard("{Enter}"); + await waitFor(() => + expect(screen.getByRole("dialog")).toHaveTextContent( + "Network request monitoring was not active for this session.", + ), + ); + }, +}; + +// Firewall active but no egress recorded. +export const NetworkNoActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 0, blocked: 0 }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("No activity")).toBeInTheDocument(); + await expect(canvas.queryByText("Blocked network requests")).toBeNull(); + }, +}; + +// Egress recorded, some blocked, across several domains. +export const NetworkActivity: Story = { + args: { + ...Default.args, + networkCalls: { total: 7, blocked: 2 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 4 }, + totalCount: 14, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("Network requests")).toBeInTheDocument(); + await expect(canvas.getByText("7")).toBeInTheDocument(); + await expect( + canvas.getByText("Blocked network requests"), + ).toBeInTheDocument(); + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.getByText("+13 more")).toBeInTheDocument(); + }, +}; + +// A single domain contacted: no "+N more" overflow. +export const NetworkSingleDomain: Story = { + args: { + ...Default.args, + networkCalls: { total: 3, blocked: 0 }, + networkDomains: { + topDomain: { domain: "api.github.com", count: 3 }, + totalCount: 1, + }, + }, + play: async ({ canvas }) => { + await expect(canvas.getByText("api.github.com")).toBeInTheDocument(); + await expect(canvas.queryByText(/more$/)).toBeNull(); + }, +}; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx index 7d6feaf93dc..389afad00c1 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionSummaryTable.tsx @@ -1,9 +1,19 @@ -import type { MinimalUser } from "#/api/typesGenerated"; +import { BanIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import type { + AIBridgeSessionNetworkCallSummary, + AIBridgeSessionNetworkDomain, + MinimalUser, +} from "#/api/typesGenerated"; import { Avatar } from "#/components/Avatar/Avatar"; import { Badge } from "#/components/Badge/Badge"; import { AIBridgeClientIcon } from "#/pages/AIBridgePage/icons/AIBridgeClientIcon"; import { AIBridgeProviderIcon } from "#/pages/AIBridgePage/icons/AIBridgeProviderIcon"; import { formatDateTime } from "#/utils/time"; +import { + NetworkMonitoringDisabled, + NetworkNoActivity, +} from "../NetworkRequestStates"; import { TokenBadges } from "../TokenBadges"; import { getProviderDisplayName } from "../utils"; @@ -21,6 +31,16 @@ interface SessionSummaryTableProps { threadCount: number; toolCallCount: number; tokenUsageMetadata?: Record; + // networkCalls is undefined when the session did not pass through Agent + // Firewall, which renders as "Disabled". + networkCalls?: AIBridgeSessionNetworkCallSummary; + // networkDomains is undefined when the session contacted no destination + // hosts. totalCount is the number of distinct domains contacted, which + // renders as a "+N more" overflow beyond topDomain. + networkDomains?: { + readonly topDomain: AIBridgeSessionNetworkDomain; + readonly totalCount: number; + }; } export const SessionSummaryTable = ({ @@ -35,12 +55,25 @@ export const SessionSummaryTable = ({ threadCount, toolCallCount, tokenUsageMetadata, + networkCalls, + networkDomains, }: SessionSummaryTableProps) => { const durationInMs = endTime !== undefined ? new Date(endTime).getTime() - new Date(startTime).getTime() : undefined; + let networkCallsValue: ReactNode; + if (networkCalls === undefined) { + networkCallsValue = ; + } else if (networkCalls.total === 0) { + networkCallsValue = ; + } else { + networkCallsValue = ( + {networkCalls.total.toLocaleString("en-US")} + ); + } + return (
@@ -163,6 +196,53 @@ export const SessionSummaryTable = ({ {toolCallCount}
+ + + +
+
+ Network requests +
+
+ {networkCallsValue} +
+
+ + {networkCalls !== undefined && networkCalls.total > 0 && ( +
+
+ Blocked network requests +
+
+ {networkCalls.blocked > 0 ? ( + + + {networkCalls.blocked.toLocaleString("en-US")} + + ) : ( + {networkCalls.blocked.toLocaleString("en-US")} + )} +
+
+ )} + + {networkDomains !== undefined && ( +
+
+ Top domains +
+
+
+ {networkDomains.topDomain.domain} +
+ {networkDomains.totalCount > 1 && ( +
+ +{(networkDomains.totalCount - 1).toLocaleString("en-US")} more +
+ )} +
+
+ )}
); }; diff --git a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx index 4157c12d887..f2dee3a81ec 100644 --- a/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx +++ b/site/src/pages/AIBridgePage/SessionThreadsPage/SessionThreadsPageView.tsx @@ -75,6 +75,10 @@ export const SessionThreadsPageView: FC = ({ 0, ); + // The API returns only the single most contacted host, alongside the total + // distinct domain count that drives the "+N more" overflow. + const topDomain = session?.network_top_domains?.[0]; + return ( <>