feat: add network calls summary to AI session threads API (#27417)

Backend for the AI session network summary. Exposes total/blocked
network calls and top destination domains on the session threads
endpoint (`GET /api/v2/ai-gateway/sessions/{id}`).

Total and blocked reuse the existing Agent Firewall aggregation from the
sessions list query, so the numbers match the sessions table. Top
domains are a new server-side aggregation
(`GetAIBridgeSessionTopDomains`) over boundary logs, using the same
interception-window correlation. There is no network-error state,
matching the current data model.

Frontend consuming these fields is in a separate stacked PR.

### PR map (merge strictly bottom-up)

This change is a 4-PR stack. Each PR depends on all the ones below it,
so merge in this exact order:

1. #27417 — backend network summary (base `main`)
2. #27418 — frontend summary rows (base #27417)
3. #27425 — backend per-call list `network_call_logs` (base #27418)
4. #27426 — frontend network-calls panel (base #27425)

Refs AIGOV-463

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cian Johnston <cian@coder.com>
This commit is contained in:
Sas Swart
2026-07-30 13:09:46 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 Cian Johnston
parent 3660ffecdd
commit 841a1765f7
16 changed files with 644 additions and 21 deletions
+19
View File
@@ -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
}
+7
View File
@@ -2821,6 +2821,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 {
+6
View File
@@ -6969,6 +6969,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()
+8
View File
@@ -1113,6 +1113,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)
+15
View File
@@ -1918,6 +1918,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()
+21
View File
@@ -309,6 +309,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)
@@ -1250,6 +1265,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)
+104 -2
View File
@@ -1325,6 +1325,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
@@ -2217,12 +2312,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
@@ -2230,7 +2326,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
@@ -2285,6 +2381,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,
+69 -2
View File
@@ -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.