mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Claude Opus 4.8
Cian Johnston
parent
3660ffecdd
commit
841a1765f7
Generated
+104
-2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user