Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions server/lib/cdpmonitor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ targets, not requests issued before their capture domains finish initializing.

## Event taxonomy

**CDP-derived** (1-to-1 with a CDP notification): `console_log`, `console_error`, `network_request`, `network_response`, `network_loading_failed`, `proxy_error` (classified from a branded 5xx response carrying the `X-Kernel-Proxy-Error` header), `page_tab_opened`, `page_navigation`, `page_dom_content_loaded`, `page_load`, `page_layout_shift`, `page_lcp`. `proxy_error` is an opt-in per-session/per-URL refinement of the raw `network` events: it is only observable while the network category (CDP collector) is running, so it is not a default-on alerting signal.
**CDP-derived** (1-to-1 with a CDP notification): `console_log`, `console_error`, `network_request`, `network_response`, `network_loading_failed`, `proxy_error` (classified from a branded 502 or 403 response carrying the `X-Kernel-Proxy-Error` header), `page_tab_opened`, `page_navigation`, `page_dom_content_loaded`, `page_load`, `page_layout_shift`, `page_lcp`. `proxy_error` is an opt-in per-session/per-URL refinement of the raw `network` events: it is only observable while the network category (CDP collector) is running, so it is not a default-on alerting signal.

**Computed** (inferred from sequences of CDP events): `network_idle` (fires when in-flight requests drop to zero), `page_layout_settled` (1 s after `page_load` with no intervening layout shifts), `page_navigation_settled` (fires once `page_dom_content_loaded` and `page_layout_settled` have both fired for the same navigation; intentionally independent of `network_idle` so that a single hung request cannot stall the event).

Expand Down Expand Up @@ -360,7 +360,7 @@ Unless otherwise noted, events also include the nav context fields described abo
| `network_request` | `request_id`, `loader_id`, `frame_id`, `document_url`, `method`, `url`, `headers`, `initiator_type`. Optional: `post_data`, `resource_type`, `is_redirect` + `redirect_url`. |
| `network_response` | `request_id`, `loader_id`, `frame_id`, `method`, `url`, `status`, `headers`. Optional: `status_text`, `mime_type`, `resource_type`, `body` (truncated text body for textual MIME types). |
| `network_loading_failed` | `request_id`, `error_text`, `canceled`. Optional (absent when the request record was not found): `url`, `loader_id`, `frame_id`, `resource_type`. |
| `proxy_error` | `request_id`, `code` (typed enum matching the metro header values), `status` (502). Optional: `raw_code` (sanitized original value when `code` is `unknown`), `url`, `loader_id`, `frame_id`, `method`, `resource_type`. Emitted when a 502 response carries the `X-Kernel-Proxy-Error` header. Unrecognized values are reported as `unknown`; all such values share one rate-limit slot. Emission is sampled to at most one per session+code+resource_type per second. WebSocket handshakes are not classified (documented non-goal). |
| `proxy_error` | `request_id`, `code` (typed enum matching the metro header values), `status` (502, or 403 when the session's network policy blocks the destination). Optional: `raw_code` (sanitized original value when `code` is `unknown`), `url`, `loader_id`, `frame_id`, `method`, `resource_type`. Emitted when a 502 or 403 response carries the `X-Kernel-Proxy-Error` header. Unrecognized values are reported as `unknown`; all such values share one rate-limit slot. Emission is sampled to at most one per session+code+resource_type per second. WebSocket handshakes are not classified (documented non-goal). A CONNECT the proxy refuses outright, such as a `network_policy_denied` CONNECT to a port other than 443, is not classified either: Chromium does not expose the refusal, so it appears only as `network_loading_failed` with `net::ERR_TUNNEL_CONNECTION_FAILED`. |

#### Page events

Expand Down
29 changes: 19 additions & 10 deletions server/lib/cdpmonitor/chrome_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,11 +343,12 @@ func (c *cdpConn) evalRect(t *testing.T, ctx context.Context, sessionID, selecto
}

// TestProxyErrorE2E drives a real browser to a stub origin that serves a branded
// 502 with the X-Kernel-Proxy-Error header and asserts the CDP collector emits a
// proxy_error telemetry event. It exercises the image-side detection
// (Network.responseReceived header classification) end to end through a real
// browser, without needing the metro host-proxy. The stub echoes the code query
// parameter as the header value so each case drives a different code.
// 502, or a 403 for network_policy_denied, with the X-Kernel-Proxy-Error header
// and asserts the CDP collector emits a proxy_error telemetry event. It
// exercises the image-side detection (Network.responseReceived header
// classification) end to end through a real browser, without needing the metro
// host-proxy. The stub echoes the code query parameter as the header value so
// each case drives a different code.
func TestProxyErrorE2E(t *testing.T) {
if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" {
t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run the real-Chromium proxy error test")
Expand All @@ -365,8 +366,12 @@ func TestProxyErrorE2E(t *testing.T) {
http.NotFound(w, r)
return
}
status := http.StatusBadGateway
if code == "network_policy_denied" {
status = http.StatusForbidden
}
w.Header().Set("X-Kernel-Proxy-Error", code)
w.WriteHeader(http.StatusBadGateway)
w.WriteHeader(status)
fmt.Fprintln(w, "<html><body>proxy error</body></html>")
}))
defer stub.Close()
Expand All @@ -383,10 +388,14 @@ func TestProxyErrorE2E(t *testing.T) {
cases := []struct {
name, header, code string
rawCode any
status int
}{
{"published code", "provider_blacklisted", "provider_blacklisted", nil},
{"code published after the first release", "restricted_route_unavailable", "restricted_route_unavailable", nil},
{"code this image does not know", "Some-Future Code", "unknown", "some_future_code"},
{"published code", "provider_blacklisted", "provider_blacklisted", nil, http.StatusBadGateway},
{"code published after the first release", "restricted_route_unavailable", "restricted_route_unavailable", nil, http.StatusBadGateway},
{"code this image does not know", "Some-Future Code", "unknown", "some_future_code", http.StatusBadGateway},
{"network policy denial", "network_policy_denied", "network_policy_denied", nil, http.StatusForbidden},
{"route proxy unavailable", "destination_route_unavailable", "destination_route_unavailable", nil, http.StatusBadGateway},
{"incomplete origin response", "origin_response_incomplete", "origin_response_incomplete", nil, http.StatusBadGateway},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -410,7 +419,7 @@ func TestProxyErrorE2E(t *testing.T) {
if tc.rawCode == nil {
require.NotContains(t, data, "raw_code")
}
require.Equal(t, float64(502), data["status"])
require.Equal(t, float64(tc.status), data["status"])
})
}
}
26 changes: 14 additions & 12 deletions server/lib/cdpmonitor/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -519,11 +519,12 @@ func (m *Monitor) handleResponseReceived(p cdpNetworkResponseReceivedParams, ses
}
m.pendReqMu.Unlock()

// Branded proxy error pages are always served as 502 (the producer
// hardcodes that status), so gate detection on it exactly and leave every
// other response paying nothing extra beyond the status compare.
// Branded proxy error pages are served as 502, or as 403 when the
// session's network policy blocks the destination, so gate detection on
// those statuses exactly and leave every other response paying nothing
// extra beyond the status compare.
code, isProxyErr := "", false
if p.Response.Status == 502 {
if p.Response.Status == 502 || p.Response.Status == 403 {
code, isProxyErr = proxyErrorCode(p.Response.Headers)
}
if !isProxyErr {
Expand Down Expand Up @@ -686,12 +687,13 @@ func (m *Monitor) handleLoadingFailed(p cdpNetworkLoadingFailedParams, sessionID
}

// proxyErrorHeader is the response header the metro egress host-proxy sets on
// branded 502 error pages to signal a proxy-layer failure to automation clients.
// branded error pages to signal a proxy-layer failure or refusal to automation
// clients.
const proxyErrorHeader = "x-kernel-proxy-error"

// proxyErrorCode returns the X-Kernel-Proxy-Error header value from a CDP
// response header map, if present. Callers gate on 5xx status before reaching
// here, so the full header map decode is already off the common path.
// response header map, if present. Callers gate on the branded statuses before
// reaching here, so the full header map decode is already off the common path.
func proxyErrorCode(resHeaders json.RawMessage) (string, bool) {
if len(resHeaders) == 0 {
return "", false
Expand Down Expand Up @@ -735,11 +737,11 @@ func (m *Monitor) proxyErrorRateLimited(sessionID, code, resourceType string) bo
}

// publishProxyError emits a typed proxy_error event for a branded proxy-layer
// failure observed on the browser's network path (a 5xx response carrying the
// X-Kernel-Proxy-Error header). The code is the header value when the published
// enum lists it. The metro egress proxy gains codes on its own release cadence,
// so a value this image does not know is reported as unknown with the sanitized
// header value in raw_code rather than dropped.
// failure observed on the browser's network path (a 502 or 403 response
// carrying the X-Kernel-Proxy-Error header). The code is the header value when
// the published enum lists it. The metro egress proxy gains codes on its own
// release cadence, so a value this image does not know is reported as unknown
// with the sanitized header value in raw_code rather than dropped.
func (m *Monitor) publishProxyError(sessionID, requestID, code string, status int, navSeq int64, method, resourceType string, url, frameID, loaderID *string) {
var rawCode *string
if code == proxyErrorUnknownCode || !oapi.BrowserProxyErrorEventDataCode(code).Valid() {
Expand Down
59 changes: 55 additions & 4 deletions server/lib/cdpmonitor/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,57 @@ func TestNetworkEvents(t *testing.T) {
assert.Equal(t, "https://blocked.example.com/", data["url"])
})

t.Run("proxy_error_network_policy_denied", func(t *testing.T) {
cp := ec.checkpoint()
// A website's own 403 carries no header and must not be classified; the
// branded 403 that follows is the positive anchor.
srv.sendToMonitor(t, map[string]any{
"method": "Network.responseReceived",
"params": map[string]any{
"requestId": "req-site-403",
"response": map[string]any{
"status": 403, "statusText": "Forbidden",
"headers": map[string]any{"Content-Type": "text/html"},
"mimeType": "text/html",
},
},
})
srv.sendToMonitor(t, map[string]any{
"method": "Network.requestWillBeSent",
"params": map[string]any{
"requestId": "req-denied",
"request": map[string]any{"method": "GET", "url": "https://unlisted.example.com/"},
},
})
srv.sendToMonitor(t, map[string]any{
"method": "Network.responseReceived",
"params": map[string]any{
"requestId": "req-denied",
"response": map[string]any{
"status": 403, "statusText": "Forbidden",
"headers": map[string]any{"X-Kernel-Proxy-Error": "network_policy_denied"},
"mimeType": "text/html",
},
},
})
ev := ec.waitForNew(t, "proxy_error", cp, 2*time.Second)
var data map[string]any
require.NoError(t, json.Unmarshal(ev.Data, &data))
assert.Equal(t, "network_policy_denied", data["code"])
assert.NotContains(t, data, "raw_code")
assert.Equal(t, float64(403), data["status"])
assert.Equal(t, "https://unlisted.example.com/", data["url"])
ec.mu.Lock()
defer ec.mu.Unlock()
count := 0
for _, ev := range ec.events[cp:] {
if ev.Type == EventProxyError {
count++
}
}
assert.Equal(t, 1, count, "a 403 without the header must not emit proxy_error")
})

t.Run("proxy_error_untracked_request", func(t *testing.T) {
cp := ec.checkpoint()
// No prior Network.requestWillBeSent, so the request is untracked. The
Expand Down Expand Up @@ -284,10 +335,10 @@ func TestNetworkEvents(t *testing.T) {
assert.Equal(t, "Document", data["resource_type"])
})

t.Run("proxy_error_gate_lt_502", func(t *testing.T) {
t.Run("proxy_error_gate_other_status", func(t *testing.T) {
cp := ec.checkpoint()
// A non-502 carrying the header must not be classified, even with a
// valid code; the following genuine 502 is the positive anchor.
// Another status carrying the header must not be classified, even with
// a valid code; the following genuine 502 is the positive anchor.
srv.sendToMonitor(t, map[string]any{
"method": "Network.responseReceived",
"params": map[string]any{
Expand Down Expand Up @@ -319,7 +370,7 @@ func TestNetworkEvents(t *testing.T) {
count++
}
}
assert.Equal(t, 1, count, "non-502 response must not emit proxy_error")
assert.Equal(t, 1, count, "a status other than 502 or 403 must not emit proxy_error")
})

t.Run("proxy_error_unknown_code_reported_as_unknown", func(t *testing.T) {
Expand Down
Loading
Loading