diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index d11652b188..a3d6bd7b84 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -2,6 +2,7 @@ import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { inferJsonShape } from "../domain/jsonShape.js"; import { paddedBase64ByteLength } from "../domain/paddedBase64.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; import { safeResponseMetadata } from "./CdpSafeMetadata.js"; import { allowedSanitizedUrl, @@ -151,6 +152,13 @@ const scriptMetadataStringBytes = (script: { const stringByteLength = (value: string | null): number => value === null ? 0 : Buffer.byteLength(value, "utf8"); +/** Approximate fixed bytes of one retained console entry or frame object, so a + * stream of minimal entries cannot admit millions of objects into the budget. */ +const RETAINED_EVENT_ENTRY_OVERHEAD_BYTES = 64; +/** Approximate fixed bytes of one captured console text value wrapper beyond + * its retained text. */ +const RETAINED_EVENT_TEXT_VALUE_BYTES = 32; + const sourceMapForScript = ( value: unknown, scriptUrl: string, @@ -473,15 +481,38 @@ export const handleConsoleAPICalled = ( argumentTypes.includes(undefined) ) state.completeness.exclude("console_events", "invalid_protocol_value"); + const type = cdpStringValue(params.type) ?? "unknown"; + const textCapture = captureConsoleText(state, arguments_); + // The shared retained-event budget bounds the whole console inventory, so a + // page cannot grow the capture without limit; a refused entry stays counted + // as a dropped event instead of vanishing. + const entryBytes = + RETAINED_EVENT_ENTRY_OVERHEAD_BYTES + + stringByteLength(type) + + stringByteLength(source.url) + + (argumentTypes ?? []).reduce( + (sum, argumentType) => sum + stringByteLength(argumentType ?? "unknown"), + 0, + ) + + textCapture.retained_bytes + + textCapture.values.length * RETAINED_EVENT_TEXT_VALUE_BYTES; + if ( + entryBytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - state.retainedEventBytes + ) { + state.completeness.drop("console_events"); + return; + } + state.retainedEventBytes += entryBytes; state.console.push({ - type: cdpStringValue(params.type) ?? "unknown", + type, timestamp: timestamp ?? null, argument_types: argumentTypes?.map((argumentType) => argumentType ?? "unknown") ?? null, url: source.url, line: integerOrNull(frame.lineNumber), column: integerOrNull(frame.columnNumber), - text_capture: captureConsoleText(state, arguments_), + text_capture: textCapture, }); }; @@ -551,11 +582,29 @@ export const handleWebSocketFrame = ( : (paddedBase64ByteLength(payload) ?? null); if (opcode === null || payloadBytes === null) state.completeness.exclude("websocket_frames", "invalid_protocol_value"); + const payloadShape = captureWebSocketShape(state, payload, opcode); + // The frame retains its shape summary, not the payload, and the shape grows + // with the payload's own keys; the shared retained-event budget bounds the + // retained frames and a refused frame stays counted as a dropped event. + const entryBytes = + RETAINED_EVENT_ENTRY_OVERHEAD_BYTES + + stringByteLength(direction) + + (payloadShape === null + ? 0 + : Buffer.byteLength(JSON.stringify(payloadShape), "utf8")); + if ( + entryBytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - state.retainedEventBytes + ) { + state.completeness.drop("websocket_frames"); + return; + } + state.retainedEventBytes += entryBytes; connection.events.push({ direction, opcode, payload_bytes: payloadBytes, - payload_shape: captureWebSocketShape(state, payload, opcode), + payload_shape: payloadShape, }); }; diff --git a/src/browser/CdpCaptureEventState.ts b/src/browser/CdpCaptureEventState.ts index 778c54812c..331fc2807e 100644 --- a/src/browser/CdpCaptureEventState.ts +++ b/src/browser/CdpCaptureEventState.ts @@ -12,6 +12,8 @@ export interface CdpCaptureEventsState { rejectedSourceMapMetadataBudgetCount: number; readonly scriptMetadataBytesById: Map; retainedScriptMetadataBytes: number; + /** Console-entry and websocket-frame bytes admitted against the shared retained-event budget. */ + retainedEventBytes: number; readonly executionContextFrames: Map; readonly network: Map; readonly networkRequestTimestamps: Map; diff --git a/src/browser/CdpCaptureEvents.eventBudget.test.ts b/src/browser/CdpCaptureEvents.eventBudget.test.ts new file mode 100644 index 0000000000..496aec5537 --- /dev/null +++ b/src/browser/CdpCaptureEvents.eventBudget.test.ts @@ -0,0 +1,146 @@ +import { expect, it } from "vitest"; + +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; +import { CdpCaptureEvents } from "./CdpCaptureEvents.js"; + +const origin = "http://127.0.0.1:43127"; + +const createEvents = (extra: Record = {}): CdpCaptureEvents => + new CdpCaptureEvents( + inspectWebPageInputSchema.parse({ + cdp_endpoint: origin, + allowed_origins: [origin], + target_id: "page", + include_console_text: true, + include_websocket_shapes: true, + ...extra, + }), + new Set([origin]), + ); + +it("admits console events until the shared retained-event budget and reports the dropped count", () => { + const events = createEvents(); + const text = "x".repeat(1024 * 1024); + const total = + Math.ceil(WEB_RUNTIME_LIMITS.retainedEventBytes / text.length) + 2; + for (let index = 0; index < total; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: text }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console.length).toBeLessThan(total); + expect(events.console.length).toBeGreaterThan(0); + const snapshot = events.completeness.snapshot(); + const dropped = total - events.console.length; + expect(snapshot.dropped_events.console_events).toBe(dropped); + expect(snapshot.dropped_events.total).toBe(dropped); +}); + +it("keeps a small console inventory admitted under the budget untouched", () => { + const events = createEvents(); + for (let index = 0; index < 16; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: `entry ${String(index)}` }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console).toHaveLength(16); + expect(events.completeness.snapshot().dropped_events.total).toBe(0); +}); + +it("drops a websocket frame whose retained shape alone exceeds the shared budget", () => { + const events = createEvents(); + events.ingest({ + method: "Network.webSocketCreated", + params: { requestId: "socket", url: `ws://${origin.slice(7)}/live` }, + }); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { opcode: 1, payloadData: '{"small":"frame"}' }, + }, + }); + const keys = Array.from( + { length: 900_000 }, + (_value, index) => `key${String(index)}`, + ); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { + opcode: 1, + payloadData: `{${keys.map((key) => `"${key}":"v"`).join(",")}}`, + }, + }, + }); + const connection = events.webSocketConnectionsById.get("socket"); + expect(connection?.events).toHaveLength(1); + const snapshot = events.completeness.snapshot(); + expect(snapshot.dropped_events.websocket_frames).toBe(1); + expect(snapshot.dropped_events.total).toBe(1); +}); + +it("spends one shared allowance across console entries and websocket frames", () => { + const events = createEvents(); + const text = "x".repeat(1024 * 1024); + // Seven megabyte console entries leave roughly one megabyte of the shared + // allowance, so a shape that fits an empty allowance no longer fits. + for (let index = 0; index < 7; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: text }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console).toHaveLength(7); + events.ingest({ + method: "Network.webSocketCreated", + params: { requestId: "socket", url: `ws://${origin.slice(7)}/live` }, + }); + const keys = Array.from( + { length: 200_000 }, + (_value, index) => `key${String(index)}`, + ); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { + opcode: 1, + payloadData: `{${keys.map((key) => `"${key}":"v"`).join(",")}}`, + }, + }, + }); + expect(events.webSocketConnectionsById.get("socket")?.events).toHaveLength(0); + const snapshot = events.completeness.snapshot(); + expect(snapshot.dropped_events.console_events).toBe(0); + expect(snapshot.dropped_events.websocket_frames).toBe(1); + expect(snapshot.dropped_events.total).toBe(1); +}); diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 02f06cfce5..b97e114334 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -26,6 +26,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { rejectedSourceMapMetadataBudgetCount = 0; readonly scriptMetadataBytesById = new Map(); retainedScriptMetadataBytes = 0; + retainedEventBytes = 0; readonly executionContextFrames = new Map(); readonly network = new Map(); readonly networkRequestTimestamps = new Map(); @@ -72,6 +73,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.rejectedSourceMapMetadataBudgetCount = 0; this.scriptMetadataBytesById.clear(); this.retainedScriptMetadataBytes = 0; + this.retainedEventBytes = 0; this.#scriptMetadataBudgetExclusionsRecorded = false; this.executionContextFrames.clear(); this.network.clear();