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
2 changes: 1 addition & 1 deletion .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
cache-dependency-path: server/go.sum

- name: Run CDP telemetry browser regressions
run: go test -race ./lib/cdpmonitor -run '^(TestNetworkCapture|TestTelemetryConnection|TestProxyErrorE2E)' -count=1 -v
run: go test -race ./lib/cdpmonitor -run '^(TestNetworkCapture|TestTelemetryConnection|TestProxyErrorE2E|TestPageWorkerEnded)' -count=1 -v
working-directory: server
env:
KERNEL_CDPMONITOR_CHROME_E2E: "1"
Expand Down
4 changes: 3 additions & 1 deletion 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 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`, `page_crashed` and `page_worker_ended` (both from `Inspector.targetCrashed`, split by target type). `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 @@ -372,6 +372,8 @@ Unless otherwise noted, events also include the nav context fields described abo
| `page_load` | Nav context + `cdp_timestamp` (CDP monotonic seconds). |
| `page_layout_shift` | Nav context + `source_frame_id`, `time`, `duration`. Optional `layout_shift_details`: `value`, `had_recent_input`. |
| `page_lcp` | Nav context + `source_frame_id`, `time`. Optional `lcp_details`: `render_time`, `load_time`, `size`, `element_id`, `url`, `node_id`. |
| `page_crashed` | `target_id`, `target_type`, `url`. The renderer process of a non-worker target (page, iframe, background page) crashed. No nav context. |
| `page_worker_ended` | `target_id`, `target_type`, `url`. Chromium sends `Inspector.targetCrashed` on a shared worker's session every time the worker ends and on a service worker's session every time it stops, so it is not a crash. Dedicated workers never send it. Chromium does not stop idle service workers while a DevTools session is attached, so the monitor's session keeps them running and idle timeouts do not produce this event. A stopped service worker restarts on the same session and reports this again on its next stop. No nav context. |

#### Computed events

Expand Down
22 changes: 18 additions & 4 deletions server/lib/cdpmonitor/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,18 @@ func (m *Monitor) dispatchEvent(msg cdpMessage) {
m.handleTimelineEvent(p, msg.SessionID)
}
case "Inspector.targetCrashed":
// No params; the crashed page is identified by the session it fires on.
// No params; the target is identified by the session it fires on.
m.handleTargetCrashed(msg.SessionID)
}
}

// handleTargetCrashed publishes a page_crashed event for a renderer crash on
// sessionID. The URL comes from the tracked target info; target id and type are
// stamped into source metadata by publishEvent.
// handleTargetCrashed publishes an event for Inspector.targetCrashed on
// sessionID. Frame targets send it only when their renderer process dies
// abnormally, so they report page_crashed. Shared and service workers send it
// every time the worker ends or stops, so they report page_worker_ended. The
// session stays tracked either way: a stopped service worker restarts on the
// same session. The URL comes from the tracked target info; target id and type
// are stamped into source metadata by publishEvent.
func (m *Monitor) handleTargetCrashed(sessionID string) {
m.sessionsMu.RLock()
info, tracked := m.sessions[sessionID]
Expand All @@ -204,6 +208,16 @@ func (m *Monitor) handleTargetCrashed(sessionID string) {
if targetType == "" {
targetType = oapi.BrowserTargetTypeOther
}
switch targetType {
case oapi.BrowserTargetTypeSharedWorker, oapi.BrowserTargetTypeServiceWorker:
data, _ := json.Marshal(oapi.BrowserPageWorkerEndedEventData{
TargetId: info.targetID,
TargetType: targetType,
Url: info.url,
})
m.publishEvent(EventPageWorkerEnded, events.Page, oapi.BrowserEventSource{Kind: oapi.Cdp}, "Inspector.targetCrashed", data, sessionID)
return
}
data, _ := json.Marshal(oapi.BrowserPageCrashedEventData{
TargetId: info.targetID,
TargetType: targetType,
Expand Down
64 changes: 64 additions & 0 deletions server/lib/cdpmonitor/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,70 @@ func TestTargetCrashed(t *testing.T) {
assert.Equal(t, "https://crash.example.com", data["url"])
}

// Chromium sends Inspector.targetCrashed when a shared worker ends or a service
// worker stops, so those targets report page_worker_ended, not page_crashed. An
// out-of-process iframe crash is a real renderer crash and stays page_crashed.
func TestTargetCrashedByTargetType(t *testing.T) {
for _, tc := range []struct {
targetType string
want string
notWant string
}{
{"shared_worker", "page_worker_ended", "page_crashed"},
{"service_worker", "page_worker_ended", "page_crashed"},
{"iframe", "page_crashed", "page_worker_ended"},
} {
t.Run(tc.targetType, func(t *testing.T) {
srv := newTestServer(t)
defer srv.close()

m, ec, cleanup := startMonitor(t, srv, nil)
defer cleanup()

sessionID := "sess-" + tc.targetType
url := "https://example.com/" + tc.targetType + ".js"
srv.sendToMonitor(t, map[string]any{
"method": "Target.attachedToTarget",
"params": map[string]any{
"sessionId": sessionID,
"targetInfo": map[string]any{
"targetId": "target-" + tc.targetType, "type": tc.targetType,
"url": url, "attached": true,
},
"waitingForDebugger": false,
},
})
require.Eventually(t, func() bool {
m.sessionsMu.RLock()
defer m.sessionsMu.RUnlock()
_, ok := m.sessions[sessionID]
return ok
}, 2*time.Second, 10*time.Millisecond)

cp := ec.checkpoint()
srv.sendToMonitor(t, map[string]any{
"method": "Inspector.targetCrashed",
"sessionId": sessionID,
"params": map[string]any{},
})
ev := ec.waitFor(t, tc.want, 2*time.Second)
assert.Equal(t, events.Page, ev.Category)
assert.Equal(t, "Inspector.targetCrashed", *ev.Source.Event)
var data map[string]any
require.NoError(t, json.Unmarshal(ev.Data, &data))
assert.Equal(t, "target-"+tc.targetType, data["target_id"])
assert.Equal(t, tc.targetType, data["target_type"])
assert.Equal(t, url, data["url"])
ec.assertNone(t, tc.notWant, cp, 200*time.Millisecond)

m.sessionsMu.RLock()
_, stillTracked := m.sessions[sessionID]
m.sessionsMu.RUnlock()
assert.True(t, stillTracked, "session must stay tracked")
})
}
}

// A crash on a session the monitor never attached to still emits page_crashed,
// but target_type must stay a valid enum ("other"), not an empty string.
func TestTargetCrashedUntracked(t *testing.T) {
Expand Down
225 changes: 225 additions & 0 deletions server/lib/cdpmonitor/page_worker_ended_chrome_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package cdpmonitor

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"syscall"
"testing"
"time"

"github.com/kernel/kernel-images/server/lib/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Chromium sends Inspector.targetCrashed on a shared worker's session when the
// worker ends and on a service worker's session when the browser stops it.
// Neither is a crash, so both must surface as page_worker_ended. A stopped
// service worker restarts on the same session, so its telemetry must keep
// flowing.
func TestPageWorkerEndedChrome(t *testing.T) {
if os.Getenv("KERNEL_CDPMONITOR_CHROME_E2E") == "" {
t.Skip("set KERNEL_CDPMONITOR_CHROME_E2E=1 to run real-Chromium worker tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/shared.js":
w.Header().Set("Content-Type", "text/javascript")
fmt.Fprint(w, `self.onconnect = event => {
const port = event.ports[0]; port.onmessage = () => self.close(); port.start();
};`)
case "/service.js":
w.Header().Set("Content-Type", "text/javascript")
fmt.Fprint(w, `self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
self.addEventListener('message', event => event.waitUntil(
fetch('/ping?marker=' + event.data).then(() => event.ports[0].postMessage('ok'))));`)
case "/ping":
fmt.Fprint(w, "pong")
default:
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, "<html><body>workers</body></html>")
}
}))
defer stub.Close()
browserWS := launchChromium(t, ctx, findChromium(t))
cdp := dialCDP(t, ctx, browserWS)
defer cdp.close()
ec := newEventCollector()
m := New(&staticUpstream{url: browserWS}, ec.publishFn(), 99, discardLogger, nil)
require.NoError(t, m.Start(ctx))
defer m.Stop()
targetID := cdp.call(t, ctx, "", "Target.createTarget", map[string]any{"url": stub.URL}).targetID(t)
sessionID := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": targetID, "flatten": true}).sessionID(t)
require.Eventually(t, func() bool {
return cdp.evalBool(ctx, sessionID, fmt.Sprintf(`location.href === %q && document.readyState === 'complete' && window.__kernelEventInjected === true`, stub.URL+"/"))
}, 15*time.Second, 50*time.Millisecond)

t.Run("shared worker closes itself", func(t *testing.T) {
cp := ec.checkpoint()
evaluateNetworkScript(t, ctx, cdp, sessionID, `(async () => {
window.sharedWorker = new SharedWorker('/shared.js'); sharedWorker.port.start(); return true;
})()`)
waitForWorkerTarget(t, ctx, cdp, "shared_worker", stub.URL+"/shared.js")
evaluateNetworkScript(t, ctx, cdp, sessionID, `(async () => { sharedWorker.port.postMessage('close'); return true; })()`)
waitForPageWorkerEnded(t, ec, cp, "shared_worker", stub.URL+"/shared.js")
})

t.Run("service worker stops and restarts", func(t *testing.T) {
evaluateNetworkScript(t, ctx, cdp, sessionID, `(async () => {
await navigator.serviceWorker.register('/service.js'); await navigator.serviceWorker.ready; return true;
})()`)
cdp.call(t, ctx, sessionID, "ServiceWorker.enable", nil)
wake := func(marker string) {
evaluateNetworkScript(t, ctx, cdp, sessionID, fmt.Sprintf(`(async () => {
const registration = await navigator.serviceWorker.ready;
const channel = new MessageChannel();
const replied = new Promise(resolve => channel.port1.onmessage = resolve);
registration.active.postMessage(%q, [channel.port2]);
await replied; return true;
})()`, marker))
}
wake("first")
first := waitForNetworkURL(t, ec, EventNetworkRequest, stub.URL+"/ping?marker=first")

cp := ec.checkpoint()
cdp.call(t, ctx, sessionID, "ServiceWorker.stopAllWorkers", nil)
ended := waitForPageWorkerEnded(t, ec, cp, "service_worker", stub.URL+"/service.js")
require.NotNil(t, ended.Source.Metadata)
workerSession := (*ended.Source.Metadata)["cdp_session_id"]
require.NotEmpty(t, workerSession)
assert.Equal(t, sessionIDOf(t, first), workerSession)

wake("second")
second := waitForNetworkURL(t, ec, EventNetworkRequest, stub.URL+"/ping?marker=second")
assert.Equal(t, workerSession, sessionIDOf(t, second), "restarted service worker must keep reporting on its session")

cp = ec.checkpoint()
cdp.call(t, ctx, sessionID, "ServiceWorker.stopAllWorkers", nil)
again := waitForPageWorkerEnded(t, ec, cp, "service_worker", stub.URL+"/service.js")
assert.Equal(t, workerSession, (*again.Source.Metadata)["cdp_session_id"])
})

t.Run("page renderer crash", func(t *testing.T) {
cp := ec.checkpoint()
crashID := cdp.call(t, ctx, "", "Target.createTarget", map[string]any{"url": stub.URL + "/crash"}).targetID(t)
crashSession := cdp.call(t, ctx, "", "Target.attachToTarget", map[string]any{"targetId": crashID, "flatten": true}).sessionID(t)
require.Eventually(t, func() bool {
return cdp.evalBool(ctx, crashSession, `document.readyState === 'complete' && window.__kernelEventInjected === true`)
}, 15*time.Second, 50*time.Millisecond)
// SIGKILL every renderer rather than sending Page.crash: a killed renderer
// is reported without waiting on Chrome's crash handler.
raw := cdp.call(t, ctx, "", "SystemInfo.getProcessInfo", nil).raw
var info struct {
Result struct {
ProcessInfo []struct {
Type string `json:"type"`
ID int `json:"id"`
} `json:"processInfo"`
} `json:"result"`
}
require.NoError(t, json.Unmarshal(raw, &info))
killed := 0
for _, process := range info.Result.ProcessInfo {
if process.Type == "renderer" {
require.NoError(t, syscall.Kill(process.ID, syscall.SIGKILL))
killed++
}
}
require.NotZero(t, killed, "no renderer processes reported")
require.Eventually(t, func() bool {
ec.mu.Lock()
defer ec.mu.Unlock()
for _, ev := range ec.events[cp:] {
if ev.Type != EventPageCrashed {
continue
}
var data struct {
TargetID string `json:"target_id"`
TargetType string `json:"target_type"`
}
if json.Unmarshal(ev.Data, &data) == nil && data.TargetID == crashID {
return data.TargetType == "page"
}
}
return false
}, 10*time.Second, 25*time.Millisecond, "missing page_crashed for the killed page")
})

ec.mu.Lock()
defer ec.mu.Unlock()
for _, ev := range ec.events {
if ev.Type != EventPageCrashed {
continue
}
var data struct {
TargetType string `json:"target_type"`
}
require.NoError(t, json.Unmarshal(ev.Data, &data))
assert.Equal(t, "page", data.TargetType, "page_crashed reported for a non-page target")
}
}

func waitForWorkerTarget(t *testing.T, ctx context.Context, cdp *cdpConn, targetType, url string) {
t.Helper()
require.Eventually(t, func() bool {
raw, err := cdp.roundtrip(ctx, "", "Target.getTargets", nil)
if err != nil {
return false
}
var result struct {
Result struct {
Targets []cdpTargetTargetInfo `json:"targetInfos"`
} `json:"result"`
}
if json.Unmarshal(raw, &result) != nil {
return false
}
for _, target := range result.Result.Targets {
if target.Type == targetType && target.URL == url {
return true
}
}
return false
}, 5*time.Second, 50*time.Millisecond, "%s target was not created", targetType)
}

func waitForPageWorkerEnded(t *testing.T, ec *eventCollector, since int, targetType, url string) events.Event {
t.Helper()
var found events.Event
require.Eventually(t, func() bool {
ec.mu.Lock()
defer ec.mu.Unlock()
for _, ev := range ec.events[since:] {
if ev.Type != EventPageWorkerEnded {
continue
}
var data struct {
TargetType string `json:"target_type"`
URL string `json:"url"`
}
if json.Unmarshal(ev.Data, &data) == nil && data.TargetType == targetType && data.URL == url {
found = ev
return true
}
}
return false
}, 10*time.Second, 25*time.Millisecond, "missing page_worker_ended for %s %s", targetType, url)
assert.Equal(t, events.Page, found.Category)
return found
}

func sessionIDOf(t *testing.T, ev events.Event) string {
t.Helper()
var data struct {
SessionID string `json:"session_id"`
}
require.NoError(t, json.Unmarshal(ev.Data, &data))
return data.SessionID
}
3 changes: 2 additions & 1 deletion server/lib/cdpmonitor/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const (
EventLayoutShift = "page_layout_shift" // PerformanceTimeline event of type "layout-shift"
EventLCP = "page_lcp" // PerformanceTimeline event of type "largest-contentful-paint"
EventTabOpened = "page_tab_opened" // Target.attachedToTarget for type=page
EventPageCrashed = "page_crashed" // Inspector.targetCrashed (renderer process crash)
EventPageCrashed = "page_crashed" // Inspector.targetCrashed on a non-worker target (renderer process crash)
EventPageWorkerEnded = "page_worker_ended" // Inspector.targetCrashed on a shared or service worker (worker ended or stopped)
)

// Computed events — synthetic events derived by computed.go state machines.
Expand Down
1 change: 1 addition & 0 deletions server/lib/events/category_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions server/lib/events/otlpconvert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ func TestToLogRecord_Severity(t *testing.T) {
"console_error": log.SeverityError,
"service_crashed": log.SeverityError,
"system_oom_kill": log.SeverityError,
"page_crashed": log.SeverityError,
"page_worker_ended": log.SeverityInfo,
"proxy_error": log.SeverityWarn, // no resource_type → not top-level document
"network_loading_failed": log.SeverityWarn,
"monitor_init_failed": log.SeverityWarn,
Expand Down
Loading
Loading