From 455647533f3eaa3676faf0b9ad316177160f3026 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 9 Sep 2026 18:39:54 -0400 Subject: [PATCH 1/6] fix(network): report iOS requests that reused a keep-alive connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CFNetwork logs a request URL only on the `com.apple.network:connection` line that opens a connection. A request that reuses a keep-alive connection emits a task summary carrying status, timing, and byte counts but no URL anywhere in the log, so a URL-keyed reader dropped it and the dump silently omitted a request that did happen. An "assert this endpoint was called on startup" check therefore read as a definite fail. Correlate a reused task summary with the connection it names and report it against that connection's origin, with `pathUnavailable` set, its status, and its timing. The request path is not in the log at all, so the dump also notes how many requests it could not name — a gap in observation now reads as a gap rather than as a negative observation. Also stop a URL parsed out of a log line from carrying the punctuation that follows it, so an entry's `url` compares equal to the endpoint under test instead of failing on a trailing comma. The correlation lives in the reader rather than a sibling module because `packages/capture-kit/src/index.ts` may not grow its eager import closure. Refs callstack/agent-device#2430 --- CHANGELOG.md | 8 ++ .../capture-kit/src/network-traffic.test.ts | 91 +++++++++++++ packages/capture-kit/src/network-traffic.ts | 124 +++++++++++++++++- packages/contracts/src/network-log.ts | 5 + .../platform-apple/src/network/runtime.ts | 15 +++ src/commands/observability/output.ts | 4 +- website/docs/docs/commands.md | 1 + 7 files changed, 244 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 214df69a91..5ece1c2f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,14 @@ disclosing that through `truncated`/`effectiveDepth` as it does unscoped. - Fixed: repeated unfiltered Android snapshots stay compact when identical element bounds arrive with a different property order. Changes to the bounds still re-emit the tree. +- Fixed: iOS `network dump` no longer omits requests that reused a keep-alive connection. + CFNetwork logs a request URL only on the line that opens a connection, so a second request to + the same host produced no `url:` line and was dropped from the dump entirely — an "this endpoint + was called" check read as a definite fail. Such a request is now reported against the origin its + connection was opened for, with `pathUnavailable` set, its status, and its timing, and the dump + carries a note saying absence of an endpoint does not prove it was not called. +- Fixed: a URL parsed out of a log line no longer keeps the punctuation that follows it, so an + entry's `url` compares equal to the endpoint under test. - Added: `replay export` supports flows that switch apps and return, preserving each `open ` target as an explicit Maestro `launchApp.appId`. - Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index 5a349d886f..2d36cf0b54 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -137,3 +137,94 @@ test('applies a validated absolute line offset to host-selected text', () => { /non-negative integer/, ); }); + +test('a URL logged mid-sentence drops the separator that follows it', () => { + const line = + '2026-09-09 18:22:27.805 Df spicygolf[33656:4505afd] [com.apple.network:connection] [C9 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite, attribution: developer] start'; + const dump = readRecentNetworkTrafficFromText(`${line}\n`, { + path: 'app.log', + exists: true, + backend: 'ios-simulator', + }); + + assert.equal(dump.entries[0]?.url, 'http://localhost:3040/v4/messages/en_US'); +}); + +// Captured from a real iOS simulator app log: `/v4/messages/en_US` opens +// connection 9 and logs its URL, then `/init` reuses connection 9 ~350ms later +// and CFNetwork logs no URL for it anywhere. +const CONNECTION_START = + '2026-09-09 18:22:27.805 Df spicygolf[33656:4505afd] [com.apple.network:connection] [C9 EA66F890-BE05-450D-BF6E-ADE5ADAC1CB8 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite, attribution: developer] start'; +const OPENING_SUMMARY = + '2026-09-09 18:22:27.816 Df spicygolf[33656:4505aed] [com.apple.CFNetwork:Summary] Task <10B2F1BA-8C9E-4877-80D2-994F1C3ED74A>.<1> summary for task success {transaction_duration_ms=11, response_status=200, connection=9, protocol="http/1.1", request_bytes=221, response_bytes=1214, cache_hit=true}'; +const REUSED_SUMMARY = + '2026-09-09 18:22:28.167 Df spicygolf[33656:4505ae4] [com.apple.CFNetwork:Summary] Task <2FAEF670-BB27-42A4-ACDD-6B6DF7D11510>.<2> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1, reused_after_ms=0, request_bytes=236, response_bytes=624, cache_hit=true}'; + +function iosDump(lines: readonly string[]) { + return readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { + path: 'app.log', + exists: true, + backend: 'ios-simulator', + }); +} + +test('a request that reused a keep-alive connection is reported against its origin', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); + const reused = dump.entries.find((entry) => entry.pathUnavailable); + + assert.equal(reused?.url, 'http://localhost:3040'); + assert.equal(reused?.status, 200); + assert.equal(reused?.durationMs, 1); + assert.equal(reused?.timestamp, '2026-09-09 18:22:28.167'); +}); + +test('a task that opened its own connection is read from its URL-bearing line only', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY]); + + assert.deepEqual( + dump.entries.map((entry) => entry.url), + ['http://localhost:3040/v4/messages/en_US'], + ); + assert.equal(dump.entries[0]?.pathUnavailable, undefined); +}); + +test('a reused request whose connection is outside the scanned window is not invented', () => { + const dump = iosDump([REUSED_SUMMARY]); + + assert.deepEqual(dump.entries, []); +}); + +test('a recycled connection number resolves to the origin most recently opened for it', () => { + const laterStart = CONNECTION_START.replace( + 'url: http://localhost:3040/v4/messages/en_US', + 'url: https://api.example.test/v1/session', + ); + const dump = iosDump([CONNECTION_START, laterStart, REUSED_SUMMARY]); + + assert.equal( + dump.entries.find((entry) => entry.pathUnavailable)?.url, + 'https://api.example.test', + ); +}); + +test('a reused request that never got a status drops the CFNetwork sentinel', () => { + const failure = REUSED_SUMMARY.replace( + 'summary for task success', + 'summary for task failure', + ).replace('response_status=200', 'response_status=-1'); + const dump = iosDump([CONNECTION_START, failure]); + const reused = dump.entries.find((entry) => entry.pathUnavailable); + + assert.equal(reused?.url, 'http://localhost:3040'); + assert.equal(reused?.status, undefined); +}); + +test('android dumps do not pay for CFNetwork correlation', () => { + const dump = readRecentNetworkTrafficFromText(`${REUSED_SUMMARY}\n`, { + path: 'app.log', + exists: true, + backend: 'android', + }); + + assert.deepEqual(dump.entries, []); +}); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 8af95ae48a..1b6f822d0c 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -21,6 +21,14 @@ import { const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] as const; const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+https?:\\/\\/`, 'i'); const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; +const CFNETWORK_CONNECTION_URL = /\[C(\d+)\b[^\]]*?\burl:\s*([^\s,\]]+)/; +const CFNETWORK_TASK_SUMMARY = /\bsummary for task (?:success|failure)\s*\{([^}]*)\}/; + +/** Connection openings in scan order, so a recycled number resolves to its most recent opening. */ +type CfNetworkConnectionIndex = ReadonlyMap< + string, + readonly Readonly<{ lineIndex: number; origin: string }>[] +>; export function mergeNetworkDumps( primary: NetworkDump, @@ -67,6 +75,9 @@ export function readRecentNetworkTrafficFromText( const startIndex = Math.max(0, allLines.length - maxScanLines); const lines = allLines.slice(startIndex); const entries: NetworkEntry[] = []; + const cfNetworkConnections = isAppleBackend(options.backend) + ? indexCfNetworkConnections(lines) + : undefined; for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { if (!lines[i]?.trim()) continue; const parsed = parseNetworkLine( @@ -76,6 +87,7 @@ export function readRecentNetworkTrafficFromText( options.backend, include, maxPayloadChars, + cfNetworkConnections, ); if (parsed) entries.push(parsed); } @@ -90,6 +102,10 @@ export function readRecentNetworkTrafficFromText( }); } +function isAppleBackend(backend: LogBackend | undefined): boolean { + return backend === 'ios-simulator' || backend === 'ios-device' || backend === 'macos'; +} + function requireLineNumberOffset(value: number | undefined): number { if (value === undefined) return 0; if (!Number.isInteger(value) || value < 0) { @@ -105,11 +121,16 @@ function parseNetworkLine( backend: LogBackend | undefined, include: NetworkDump['include'], maxPayloadChars: number, + cfNetworkConnections: CfNetworkConnectionIndex | undefined, ): NetworkEntry | null { const line = lines[lineIndex]?.trim(); if (!line) return null; const maybeJson = parseEmbeddedNetworkJson(line); - const identity = parseNetworkIdentity(line, maybeJson); + const identity = + parseNetworkIdentity(line, maybeJson) ?? + (cfNetworkConnections + ? parseCfNetworkReusedTaskIdentity(line, cfNetworkConnections, lineIndex) + : null); if (!identity) return null; const result = createNetworkEntry(line, lineNumber, identity, maxPayloadChars); if (backend === 'android') enrichNetworkEntryFromAndroidLines(result, lines, lineIndex); @@ -121,6 +142,8 @@ type NetworkIdentity = Readonly<{ method?: string; url: string; status?: number; + durationMs?: number; + pathUnavailable?: boolean; }>; function parseNetworkIdentity( @@ -152,7 +175,12 @@ function parseNetworkUrl( line: string, maybeJson: Record | null, ): string | undefined { - return readNetworkJsonString(maybeJson, ['url', 'requestUrl']) ?? URL_REGEX.exec(line)?.[0]; + const json = readNetworkJsonString(maybeJson, ['url', 'requestUrl']); + if (json) return json; + const matched = URL_REGEX.exec(line)?.[0]; + // A URL logged mid-sentence carries the separator that follows it, and an + // equality check against the endpoint under test fails on the stray byte. + return matched === undefined ? undefined : matched.replace(/[,.;:]+$/, ''); } function parseNetworkStatus( @@ -190,7 +218,7 @@ function createNetworkEntry( ...identity, timestamp: parseNetworkTimestamp(line), packetId: parseAndroidPacketId(line) ?? undefined, - durationMs: parseAndroidDurationMs(line) ?? undefined, + durationMs: identity.durationMs ?? parseAndroidDurationMs(line) ?? undefined, raw: truncate(line, maxPayloadChars), line: lineNumber, }; @@ -241,3 +269,93 @@ function clampInt(value: number | undefined, fallback: number, min: number, max: ? fallback : Math.max(min, Math.min(max, value)); } + +/** + * CFNetwork logs a request URL only on the `com.apple.network:connection` line + * that opens a connection. A request that reuses a keep-alive connection emits + * a task summary with status, timing, and byte counts but no URL anywhere, so + * a URL-keyed reader drops it and an "endpoint was never called" check reads as + * a definite negative. Resolving the summary against the connection it reused + * recovers the origin; the request path is not in the log at all. + */ +function indexCfNetworkConnections(lines: readonly string[]): CfNetworkConnectionIndex { + const index = new Map(); + for (const [lineIndex, line] of lines.entries()) { + const match = CFNETWORK_CONNECTION_URL.exec(line); + if (!match) continue; + const origin = readCfNetworkOrigin(match[2] as string); + if (!origin) continue; + const openings = index.get(match[1] as string); + if (openings) openings.push({ lineIndex, origin }); + else index.set(match[1] as string, [{ lineIndex, origin }]); + } + return index; +} + +function parseCfNetworkReusedTaskIdentity( + line: string, + index: CfNetworkConnectionIndex, + lineIndex: number, +): NetworkIdentity | null { + const summary = CFNETWORK_TASK_SUMMARY.exec(line); + if (!summary) return null; + const fields = readCfNetworkSummaryFields(summary[1] as string); + // Without `reused` the task opened its own connection, so a URL-bearing line + // for it is already in the log and this summary would only duplicate it. + if (fields.get('reused') !== '1') return null; + const connection = fields.get('connection'); + if (connection === undefined) return null; + const origin = resolveCfNetworkOrigin(index, connection, lineIndex); + if (!origin) return null; + return { + url: origin, + status: readCfNetworkStatus(fields.get('response_status')), + durationMs: readCfNetworkCount(fields.get('transaction_duration_ms')), + pathUnavailable: true, + }; +} + +function resolveCfNetworkOrigin( + index: CfNetworkConnectionIndex, + connection: string, + lineIndex: number, +): string | undefined { + const openings = index.get(connection); + if (!openings) return undefined; + let resolved: string | undefined; + for (const opening of openings) { + if (opening.lineIndex > lineIndex) break; + resolved = opening.origin; + } + return resolved; +} + +function readCfNetworkSummaryFields(body: string): ReadonlyMap { + const fields = new Map(); + for (const pair of body.split(',')) { + const separator = pair.indexOf('='); + if (separator === -1) continue; + fields.set(pair.slice(0, separator).trim(), pair.slice(separator + 1).trim()); + } + return fields; +} + +function readCfNetworkOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +// CFNetwork reports `-1` for a task that never received a response status. +function readCfNetworkStatus(value: string | undefined): number | undefined { + const status = readCfNetworkCount(value); + return status !== undefined && status > 0 ? status : undefined; +} + +function readCfNetworkCount(value: string | undefined): number | undefined { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/packages/contracts/src/network-log.ts b/packages/contracts/src/network-log.ts index a561d8c1e5..a0de7e105a 100644 --- a/packages/contracts/src/network-log.ts +++ b/packages/contracts/src/network-log.ts @@ -13,6 +13,11 @@ export type NetworkEntry = { headers?: string; requestBody?: string; responseBody?: string; + /** + * The reader observed this request but not its path: `url` is the origin of + * the connection it reused. Absent means `url` is the request URL as logged. + */ + pathUnavailable?: boolean; raw: string; line: number; }; diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 25541c3240..e08e9158a5 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -36,6 +36,7 @@ export async function dumpAppleNetworkTraffic( } } } + appendUnnamedRequestNote(notes, dump); appendLifecycleNote(notes, device, input); if (dump.entries.length === 0) notes.push(noEntriesNote(device)); return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); @@ -113,6 +114,20 @@ function buildPredicate(appBundleId: string): string { ].join(' OR '); } +/** + * CFNetwork logs a request URL only when a connection is opened, so a request + * that reused a keep-alive connection is reported against its connection's + * origin with no path. Saying so keeps "this endpoint was never called" from + * being read off a dump that could not name every request it observed. + */ +function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void { + const unnamed = dump.entries.filter((entry) => entry.pathUnavailable).length; + if (unnamed === 0) return; + notes.push( + `${unnamed} request${unnamed === 1 ? '' : 's'} reused a keep-alive connection, so CFNetwork logged no request URL. ${unnamed === 1 ? 'It is' : 'They are'} listed against the origin the connection was opened for, without a path: absence of an endpoint in this dump does not prove it was not called.`, + ); +} + function appendLifecycleNote(notes: string[], device: DeviceInfo, input: NetworkDumpInput): void { if (!input.appLogSnapshot) { notes.push( diff --git a/src/commands/observability/output.ts b/src/commands/observability/output.ts index b851079099..6c40de7293 100644 --- a/src/commands/observability/output.ts +++ b/src/commands/observability/output.ts @@ -309,7 +309,9 @@ function formatNetworkEntry(entry: NetworkCliEntry): string[] { const status = entry.status !== undefined ? ` status=${entry.status}` : ''; const timestamp = entry.timestamp ? `${entry.timestamp} ` : ''; const durationMs = entry.durationMs !== undefined ? ` durationMs=${entry.durationMs}` : ''; - const lines = [`${timestamp}${method} ${url}${status}${durationMs}`]; + const path = + 'pathUnavailable' in entry && entry.pathUnavailable ? ' (request path not logged)' : ''; + const lines = [`${timestamp}${method} ${url}${path}${status}${durationMs}`]; if (entry.headers) { appendNetworkEntryBody(lines, 'headers', entry.headers); } else { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index e6cbb2a920..5604741a4c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -991,6 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - iOS simulator log capture now streams from inside the simulator with `simctl spawn log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse. - iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. - On iOS, `network dump` can return zero HTTP entries for real app activity when the app does not emit request metadata into Unified Logging. The response notes now distinguish between an empty repro window and a non-network app log window. +- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing, and the dump notes how many there were. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. From 7ed67cda895c2d7067e8d1bde47c98b9ae91f5fe Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Wed, 9 Sep 2026 19:00:27 -0400 Subject: [PATCH 2/6] fix(network): count keep-alive requests the reader cannot name at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the parent commit found the same definite-negative it fixes, one level down: a reused task summary whose connection was opened before the scanned window resolves to no origin, so it produced no entry and no signal — an empty dump reporting "No HTTP(s) entries were found" for a window that demonstrably carried traffic. Count those in the dump's `unnamedRequests` and say so in the notes, so an unnameable request is still a reported observation. Also order the Apple note builders so the keep-alive note no longer trips the `notes.length === 0` guard that suppresses lifecycle guidance, and give the android-backend test a fixture an Apple dump would actually resolve, so the backend gate it names is the thing it proves. --- CHANGELOG.md | 6 ++- .../capture-kit/src/network-traffic.test.ts | 24 +++++++++- packages/capture-kit/src/network-traffic.ts | 23 +++++++++ packages/contracts/src/network-traffic.ts | 6 +++ .../src/network/runtime.test.ts | 47 +++++++++++++++++++ .../platform-apple/src/network/runtime.ts | 26 +++++++--- website/docs/docs/commands.md | 2 +- 7 files changed, 124 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ece1c2f72..a424a6b2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,10 @@ CFNetwork logs a request URL only on the line that opens a connection, so a second request to the same host produced no `url:` line and was dropped from the dump entirely — an "this endpoint was called" check read as a definite fail. Such a request is now reported against the origin its - connection was opened for, with `pathUnavailable` set, its status, and its timing, and the dump - carries a note saying absence of an endpoint does not prove it was not called. + connection was opened for, with `pathUnavailable` set, its status, and its timing. A reused + request whose connection was opened before the scanned window cannot be named at all; those are + counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was + observed. The notes say absence of an endpoint does not prove it was not called. - Fixed: a URL parsed out of a log line no longer keeps the punctuation that follows it, so an entry's `url` compares equal to the endpoint under test. - Added: `replay export` supports flows that switch apps and return, preserving each diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index 2d36cf0b54..c95e90a32f 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -65,6 +65,7 @@ test('keeps missing canonical app-log text distinct and merges recovery first', scannedLines: 0, matchedLines: 0, entries: [], + unnamedRequests: 0, include: 'summary', limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, }); @@ -220,11 +221,32 @@ test('a reused request that never got a status drops the CFNetwork sentinel', () }); test('android dumps do not pay for CFNetwork correlation', () => { - const dump = readRecentNetworkTrafficFromText(`${REUSED_SUMMARY}\n`, { + const lines = `${[CONNECTION_START, REUSED_SUMMARY].join('\n')}\n`; + assert.equal(iosDump([CONNECTION_START, REUSED_SUMMARY]).entries.length, 2); + + const dump = readRecentNetworkTrafficFromText(lines, { path: 'app.log', exists: true, backend: 'android', }); + assert.deepEqual( + dump.entries.map((entry) => entry.url), + ['http://localhost:3040/v4/messages/en_US'], + ); + assert.equal(dump.unnamedRequests, 0); +}); + +test('a reused request whose connection opened before the window is counted, not dropped', () => { + const dump = iosDump([REUSED_SUMMARY]); + assert.deepEqual(dump.entries, []); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a resolved reused request is named, not counted as unnamed', () => { + const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); + + assert.equal(dump.unnamedRequests, 0); + assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1); }); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 1b6f822d0c..45cd314f9e 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -48,6 +48,7 @@ export function mergeNetworkDumps( ...primary, matchedLines: entries.length, entries: Object.freeze(entries), + unnamedRequests: Math.max(primary.unnamedRequests ?? 0, secondary.unnamedRequests ?? 0), }); } @@ -67,6 +68,7 @@ export function readRecentNetworkTrafficFromText( scannedLines: 0, matchedLines: 0, entries: Object.freeze([]), + unnamedRequests: 0, include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); @@ -97,6 +99,9 @@ export function readRecentNetworkTrafficFromText( scannedLines: lines.length, matchedLines: entries.length, entries: Object.freeze(entries), + unnamedRequests: cfNetworkConnections + ? countUnnamedCfNetworkRequests(lines, cfNetworkConnections) + : 0, include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); @@ -315,6 +320,24 @@ function parseCfNetworkReusedTaskIdentity( }; } +function countUnnamedCfNetworkRequests( + lines: readonly string[], + index: CfNetworkConnectionIndex, +): number { + let unnamed = 0; + for (const [lineIndex, line] of lines.entries()) { + if (!line.includes('summary for task')) continue; + const summary = CFNETWORK_TASK_SUMMARY.exec(line); + if (!summary) continue; + const fields = readCfNetworkSummaryFields(summary[1] as string); + if (fields.get('reused') !== '1') continue; + const connection = fields.get('connection'); + if (connection !== undefined && resolveCfNetworkOrigin(index, connection, lineIndex)) continue; + unnamed += 1; + } + return unnamed; +} + function resolveCfNetworkOrigin( index: CfNetworkConnectionIndex, connection: string, diff --git a/packages/contracts/src/network-traffic.ts b/packages/contracts/src/network-traffic.ts index c9185c3a01..388a6b6c13 100644 --- a/packages/contracts/src/network-traffic.ts +++ b/packages/contracts/src/network-traffic.ts @@ -20,6 +20,12 @@ export type NetworkDump = Readonly<{ scannedLines: number; matchedLines: number; entries: readonly NetworkEntry[]; + /** + * Requests the reader observed but could not name at all, so they are absent + * from `entries`: an empty dump with a non-zero count is a failed capture, + * not evidence that nothing was requested. + */ + unnamedRequests?: number; include: NonNullable; limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>; }>; diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 0523dd04fd..b8279a79aa 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -210,3 +210,50 @@ function unusedAppLogHost(): Omit< 'appleTools' | 'commands' | 'appLogs' | 'networkTransports' >; } + +// Real iOS simulator lines: `/init` reused the connection `/v4/messages/en_US` +// opened, and CFNetwork logged no URL for it. +const CONNECTION_START = + '2026-09-09 18:22:27.805 Df app[1:2] [com.apple.network:connection] [C9 EA66F890 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite] start'; +const REUSED_SUMMARY = + '2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<2> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1, request_bytes=236, response_bytes=624}'; + +test('a keep-alive request reported against its origin does not silence lifecycle guidance', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: `${[CONNECTION_START, REUSED_SUMMARY].join('\n')}\n`, + runSimctl: vi.fn(), + }), + simulator, + input({ appLogSnapshot: { state: 'ended', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.notes).toEqual([ + expect.stringContaining('Session app log stream is inactive'), + expect.stringContaining('reused a keep-alive connection'), + ]); + expect(result.notes[1]).toContain('1 listed against the origin'); +}); + +test('a keep-alive request whose connection predates the window keeps the dump from reading empty', async () => { + const result = await dumpAppleNetworkTraffic( + host({ + text: `${REUSED_SUMMARY}\n`, + runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), + }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes[0]).toContain('does not prove it was not called'); +}); diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index e08e9158a5..0437afba6a 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -36,8 +36,8 @@ export async function dumpAppleNetworkTraffic( } } } - appendUnnamedRequestNote(notes, dump); appendLifecycleNote(notes, device, input); + appendUnnamedRequestNote(notes, dump); if (dump.entries.length === 0) notes.push(noEntriesNote(device)); return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); } @@ -121,11 +121,25 @@ function buildPredicate(appBundleId: string): string { * being read off a dump that could not name every request it observed. */ function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void { - const unnamed = dump.entries.filter((entry) => entry.pathUnavailable).length; - if (unnamed === 0) return; - notes.push( - `${unnamed} request${unnamed === 1 ? '' : 's'} reused a keep-alive connection, so CFNetwork logged no request URL. ${unnamed === 1 ? 'It is' : 'They are'} listed against the origin the connection was opened for, without a path: absence of an endpoint in this dump does not prove it was not called.`, - ); + const againstOrigin = dump.entries.filter((entry) => entry.pathUnavailable).length; + const unresolved = dump.unnamedRequests ?? 0; + const observed = againstOrigin + unresolved; + if (observed === 0) return; + const parts = [ + `${observed} request${observed === 1 ? '' : 's'} reused a keep-alive connection, so CFNetwork logged no request URL.`, + ]; + if (againstOrigin > 0) { + parts.push( + `${againstOrigin} listed against the origin the connection was opened for, without a path.`, + ); + } + if (unresolved > 0) { + parts.push( + `${unresolved} opened before this scan window and are missing from the entries entirely; scan more lines, or run logs clear --restart before the repro.`, + ); + } + parts.push('Absence of an endpoint in this dump does not prove it was not called.'); + notes.push(parts.join(' ')); } function appendLifecycleNote(notes: string[], device: DeviceInfo, input: NetworkDumpInput): void { diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 5604741a4c..8bed29d648 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -991,7 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - iOS simulator log capture now streams from inside the simulator with `simctl spawn log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse. - iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. - On iOS, `network dump` can return zero HTTP entries for real app activity when the app does not emit request metadata into Unified Logging. The response notes now distinguish between an empty repro window and a non-network app log window. -- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing, and the dump notes how many there were. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. +- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are counted in `unnamedRequests` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. From b98ee064b70d58a205f62a6a07c5665c896cb6a2 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 10 Sep 2026 08:57:47 -0400 Subject: [PATCH 3/6] fix(network): scope connection correlation to the process that opened it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the parent commits: three ways the reader still answers with something other than what it observed. A connection number is only meaningful within one process, but the index keyed on the number alone, so an app that relaunched and reopened the same number inherited the origin its predecessor had contacted — a request attributed to a host it never reached, which is worse than dropping it. Key the index by the compact log's `name[pid]` and the connection number together; a line whose process cannot be read correlates to nothing and its traffic stays unnamed. The simulator recovery pass merged its dump only when it carried entries, so a recovery window holding nothing but unnameable reused-task summaries discarded that count and the response still reported an empty window. Merge whenever the pass observed traffic in either form, and reserve the "none looked like HTTP traffic" note for a pass that found neither. The trailing-separator strip was global, so a valid URL ending in punctuation became a different endpoint. Take the URL from the delimited `url:` field where the format establishes the separator, and leave a bare URL exactly as matched. Regressions cover each: the same connection number under a different pid, an unreadable process identity, recovery-only unnamed traffic, and a path that legitimately ends in a period. --- CHANGELOG.md | 6 +- .../capture-kit/src/network-traffic.test.ts | 57 +++++++++++++++++++ packages/capture-kit/src/network-traffic.ts | 44 +++++++++----- .../src/network/runtime.test.ts | 45 +++++++++++++++ .../platform-apple/src/network/runtime.ts | 41 +++++++++---- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a424a6b2ac..7fc2eb27a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,10 @@ request whose connection was opened before the scanned window cannot be named at all; those are counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was observed. The notes say absence of an endpoint does not prove it was not called. -- Fixed: a URL parsed out of a log line no longer keeps the punctuation that follows it, so an - entry's `url` compares equal to the endpoint under test. +- Fixed: a URL logged as a delimited `url: ,` field no longer keeps the separator the log + format put after it, so an entry's `url` compares equal to the endpoint under test. A bare URL + elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of + the path. - Added: `replay export` supports flows that switch apps and return, preserving each `open ` target as an explicit Maestro `launchApp.appId`. - Added: `replay export` converts recorded `home` actions to Maestro `pressKey: Home`, allowing diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index c95e90a32f..e6cdb34acc 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -250,3 +250,60 @@ test('a resolved reused request is named, not counted as unnamed', () => { assert.equal(dump.unnamedRequests, 0); assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1); }); + +function withProcess(line: string, process: string): string { + const swapped = line.replace(/spicygolf\[\d+:[0-9a-f]+\]/, process); + if (swapped === line) throw new Error('fixture process token not found'); + return swapped; +} + +test('a recycled connection number does not inherit the origin of a previous process', () => { + const relaunchedSummary = REUSED_SUMMARY.replace( + 'spicygolf[33656:4505ae4]', + 'spicygolf[40001:4505ae4]', + ); + const dump = iosDump([CONNECTION_START, relaunchedSummary]); + + assert.deepEqual( + dump.entries.filter((entry) => entry.pathUnavailable), + [], + ); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a connection number is resolved within the process that opened it', () => { + const otherProcessStart = withProcess(CONNECTION_START, 'otherapp[40001:4505afd]').replace( + 'url: http://localhost:3040/v4/messages/en_US', + 'url: https://wrong.example.test/x', + ); + const dump = iosDump([otherProcessStart, CONNECTION_START, REUSED_SUMMARY]); + + assert.equal(dump.entries.find((entry) => entry.pathUnavailable)?.url, 'http://localhost:3040'); +}); + +test('a line with no readable process identity leaves its traffic unnamed', () => { + const dump = iosDump([ + CONNECTION_START.replace('spicygolf[33656:4505afd]', 'spicygolf'), + REUSED_SUMMARY.replace('spicygolf[33656:4505ae4]', 'spicygolf'), + ]); + + assert.deepEqual( + dump.entries.filter((entry) => entry.pathUnavailable), + [], + ); + assert.equal(dump.unnamedRequests, 1); +}); + +test('a URL whose path ends in punctuation is not truncated into a different endpoint', () => { + const dump = iosDump([ + '2026-09-09 18:22:27.805 Df app[1:2] [com.example:Default] GET https://example.test/release. status=200', + ]); + + assert.equal(dump.entries[0]?.url, 'https://example.test/release.'); +}); + +test('a delimited url: field drops the separator the format put after it', () => { + const dump = iosDump([CONNECTION_START]); + + assert.equal(dump.entries[0]?.url, 'http://localhost:3040/v4/messages/en_US'); +}); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 45cd314f9e..db40051210 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -23,6 +23,14 @@ const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+h const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; const CFNETWORK_CONNECTION_URL = /\[C(\d+)\b[^\]]*?\burl:\s*([^\s,\]]+)/; const CFNETWORK_TASK_SUMMARY = /\bsummary for task (?:success|failure)\s*\{([^}]*)\}/; +// `name[pid:tid]` in the compact unified-log prefix. Connection numbers restart +// per process, so a number alone would let a relaunched app inherit the origin +// its predecessor opened; the pid is what keeps those apart. +const LOG_PROCESS_IDENTITY = /(?:^|\s)(\S+)\[(\d+):[0-9a-f]+\]/; +// `url: ,` is a delimited field, so the separator belongs to the format +// rather than to the URL. A bare URL elsewhere keeps whatever it matched, since +// nothing there establishes that trailing punctuation is not part of the path. +const URL_FIELD = /\burl:\s*(https?:\/\/[^\s,\]]+)/i; /** Connection openings in scan order, so a recycled number resolves to its most recent opening. */ type CfNetworkConnectionIndex = ReadonlyMap< @@ -182,10 +190,7 @@ function parseNetworkUrl( ): string | undefined { const json = readNetworkJsonString(maybeJson, ['url', 'requestUrl']); if (json) return json; - const matched = URL_REGEX.exec(line)?.[0]; - // A URL logged mid-sentence carries the separator that follows it, and an - // equality check against the endpoint under test fails on the stray byte. - return matched === undefined ? undefined : matched.replace(/[,.;:]+$/, ''); + return URL_FIELD.exec(line)?.[1] ?? URL_REGEX.exec(line)?.[0]; } function parseNetworkStatus( @@ -288,15 +293,27 @@ function indexCfNetworkConnections(lines: readonly string[]): CfNetworkConnectio for (const [lineIndex, line] of lines.entries()) { const match = CFNETWORK_CONNECTION_URL.exec(line); if (!match) continue; - const origin = readCfNetworkOrigin(match[2] as string); - if (!origin) continue; - const openings = index.get(match[1] as string); + const key = cfNetworkConnectionKey(line, match[1] as string); + const origin = key === undefined ? undefined : readCfNetworkOrigin(match[2] as string); + if (!origin || key === undefined) continue; + const openings = index.get(key); if (openings) openings.push({ lineIndex, origin }); - else index.set(match[1] as string, [{ lineIndex, origin }]); + else index.set(key, [{ lineIndex, origin }]); } return index; } +/** + * A connection is only the same connection within one process. A line whose + * process cannot be read correlates to nothing, so its traffic stays unnamed + * rather than borrowing an origin the app never contacted. + */ +function cfNetworkConnectionKey(line: string, connection: string): string | undefined { + const process = LOG_PROCESS_IDENTITY.exec(line); + if (!process) return undefined; + return `${process[1]}[${process[2]}]#${connection}`; +} + function parseCfNetworkReusedTaskIdentity( line: string, index: CfNetworkConnectionIndex, @@ -309,8 +326,8 @@ function parseCfNetworkReusedTaskIdentity( // for it is already in the log and this summary would only duplicate it. if (fields.get('reused') !== '1') return null; const connection = fields.get('connection'); - if (connection === undefined) return null; - const origin = resolveCfNetworkOrigin(index, connection, lineIndex); + const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); + const origin = key === undefined ? undefined : resolveCfNetworkOrigin(index, key, lineIndex); if (!origin) return null; return { url: origin, @@ -332,7 +349,8 @@ function countUnnamedCfNetworkRequests( const fields = readCfNetworkSummaryFields(summary[1] as string); if (fields.get('reused') !== '1') continue; const connection = fields.get('connection'); - if (connection !== undefined && resolveCfNetworkOrigin(index, connection, lineIndex)) continue; + const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); + if (key !== undefined && resolveCfNetworkOrigin(index, key, lineIndex)) continue; unnamed += 1; } return unnamed; @@ -340,10 +358,10 @@ function countUnnamedCfNetworkRequests( function resolveCfNetworkOrigin( index: CfNetworkConnectionIndex, - connection: string, + key: string, lineIndex: number, ): string | undefined { - const openings = index.get(connection); + const openings = index.get(key); if (!openings) return undefined; let resolved: string | undefined; for (const opening of openings) { diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index b8279a79aa..8630ee7002 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -257,3 +257,48 @@ test('a keep-alive request whose connection predates the window keeps the dump f ]); expect(result.notes[0]).toContain('does not prove it was not called'); }); + +test('simulator recovery keeps traffic it saw but could not name', async () => { + const runSimctl = vi.fn(async () => ({ + stdout: ['Timestamp Ty Process[PID:TID]', REUSED_SUMMARY].join('\n'), + stderr: '', + exitCode: 0, + })); + const result = await dumpAppleNetworkTraffic( + host({ text: '', runSimctl }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic'); +}); + +test('recovery-only traffic that cannot be named is still reported, not called empty', async () => { + const runSimctl = vi.fn(async () => ({ + stdout: ['Timestamp Ty Process[PID:TID]', REUSED_SUMMARY].join('\n'), + stderr: '', + exitCode: 0, + })); + const result = await dumpAppleNetworkTraffic( + host({ text: '', runSimctl }), + simulator, + input({ appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.unnamedRequests).toBe(1); + expect(result.notes).toEqual([ + expect.stringContaining('1 opened before this scan window'), + expect.stringContaining('No HTTP(s) entries were found'), + ]); + expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic'); +}); diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 0437afba6a..8a66f841b0 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -23,18 +23,7 @@ export async function dumpAppleNetworkTraffic( const notes: string[] = []; if (canRecoverSimulator(device, input, dump)) { const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); - if (recovery) { - if (recovery.dump.entries.length > 0) { - dump = mergeNetworkDumps(recovery.dump, dump, input.maxEntries); - notes.push( - `Recovered ${recovery.dump.entries.length} iOS simulator HTTP entr${recovery.dump.entries.length === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, - ); - } else if (recovery.lineCount > 0) { - notes.push( - `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, - ); - } - } + if (recovery) dump = mergeRecoveredTraffic(notes, dump, recovery, input.maxEntries); } appendLifecycleNote(notes, device, input); appendUnnamedRequestNote(notes, dump); @@ -42,6 +31,34 @@ export async function dumpAppleNetworkTraffic( return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); } +/** + * Traffic the recovery pass saw but could not name is still traffic, so it is + * merged for its count alone; only a pass that found nothing at all reports the + * window as non-network. + */ +function mergeRecoveredTraffic( + notes: string[], + dump: NetworkDump, + recovery: { dump: NetworkDump; lineCount: number }, + maxEntries: number, +): NetworkDump { + const recovered = recovery.dump.entries.length; + if (recovered === 0 && (recovery.dump.unnamedRequests ?? 0) === 0) { + if (recovery.lineCount > 0) { + notes.push( + `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, + ); + } + return dump; + } + if (recovered > 0) { + notes.push( + `Recovered ${recovered} iOS simulator HTTP entr${recovered === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, + ); + } + return mergeNetworkDumps(recovery.dump, dump, maxEntries); +} + function canRecoverSimulator( device: DeviceInfo, input: NetworkDumpInput, From 63c21b6ed828ca4035ea3a83621673296e3625fd Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 10 Sep 2026 10:25:00 -0400 Subject: [PATCH 4/6] fix(network): reconcile unnamed keep-alive requests across scan windows The app log and the simulator recovery pass cover different, sometimes overlapping windows, so taking the larger of their two unnamed counts was wrong in both directions: two unnameable requests in one window and three in the other reported three rather than five, and a request the recovery pass resolved stayed counted as unnamed from the app log. Carry the identities instead of a count. Every CFNetwork line names its request as `Task .`, scoped here to the emitting process, so the same request seen in two windows is recognisable as one. A merge unions the unnamed identities and subtracts anything either window managed to name, and a resolved reused request carries its identity as `packetId` so that subtraction has something to key on. `NetworkDump.unnamedRequests` becomes `unnamedRequestIds`, since a list of identities is what makes the reconciliation exact rather than a lower bound. Regressions cover disjoint windows, overlapping windows, and a request one window named while the other could not. --- CHANGELOG.md | 6 +- .../capture-kit/src/network-traffic.test.ts | 49 +++++++++++-- packages/capture-kit/src/network-traffic.ts | 73 ++++++++++++++----- packages/contracts/src/network-traffic.ts | 10 ++- .../src/network/runtime.test.ts | 6 +- .../platform-apple/src/network/runtime.ts | 4 +- website/docs/docs/commands.md | 2 +- 7 files changed, 113 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc2eb27a3..8b1e026deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,10 @@ was called" check read as a definite fail. Such a request is now reported against the origin its connection was opened for, with `pathUnavailable` set, its status, and its timing. A reused request whose connection was opened before the scanned window cannot be named at all; those are - counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was - observed. The notes say absence of an endpoint does not prove it was not called. + listed in the dump's `unnamedRequestIds`, so an empty result still reports that traffic was + observed. Identities rather than a count, so the app-log and recovery windows reconcile to the + requests actually seen instead of double-counting overlapping traffic or under-reporting + disjoint traffic. The notes say absence of an endpoint does not prove it was not called. - Fixed: a URL logged as a delimited `url: ,` field no longer keeps the separator the log format put after it, so an entry's `url` compares equal to the endpoint under test. A bare URL elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index e6cdb34acc..f4e18b8131 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -65,7 +65,7 @@ test('keeps missing canonical app-log text distinct and merges recovery first', scannedLines: 0, matchedLines: 0, entries: [], - unnamedRequests: 0, + unnamedRequestIds: [], include: 'summary', limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, }); @@ -234,20 +234,20 @@ test('android dumps do not pay for CFNetwork correlation', () => { dump.entries.map((entry) => entry.url), ['http://localhost:3040/v4/messages/en_US'], ); - assert.equal(dump.unnamedRequests, 0); + assert.equal(dump.unnamedRequestIds?.length, 0); }); test('a reused request whose connection opened before the window is counted, not dropped', () => { const dump = iosDump([REUSED_SUMMARY]); assert.deepEqual(dump.entries, []); - assert.equal(dump.unnamedRequests, 1); + assert.equal(dump.unnamedRequestIds?.length, 1); }); test('a resolved reused request is named, not counted as unnamed', () => { const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); - assert.equal(dump.unnamedRequests, 0); + assert.equal(dump.unnamedRequestIds?.length, 0); assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1); }); @@ -268,7 +268,7 @@ test('a recycled connection number does not inherit the origin of a previous pro dump.entries.filter((entry) => entry.pathUnavailable), [], ); - assert.equal(dump.unnamedRequests, 1); + assert.equal(dump.unnamedRequestIds?.length, 1); }); test('a connection number is resolved within the process that opened it', () => { @@ -291,7 +291,7 @@ test('a line with no readable process identity leaves its traffic unnamed', () = dump.entries.filter((entry) => entry.pathUnavailable), [], ); - assert.equal(dump.unnamedRequests, 1); + assert.equal(dump.unnamedRequestIds?.length, 1); }); test('a URL whose path ends in punctuation is not truncated into a different endpoint', () => { @@ -307,3 +307,40 @@ test('a delimited url: field drops the separator the format put after it', () => assert.equal(dump.entries[0]?.url, 'http://localhost:3040/v4/messages/en_US'); }); + +// A second reused request on the same connection, distinct from REUSED_SUMMARY. +const SECOND_REUSED_SUMMARY = REUSED_SUMMARY.replace( + 'Task <2FAEF670-BB27-42A4-ACDD-6B6DF7D11510>.<2>', + 'Task <9C1D77B4-0E52-4A18-9D31-7F0A2B4C6E88>.<3>', +); + +test('two windows over disjoint unnamed traffic report both requests, not the larger count', () => { + const appLog = iosDump([REUSED_SUMMARY]); + const recovery = iosDump([SECOND_REUSED_SUMMARY]); + + const merged = mergeNetworkDumps(recovery, appLog, 200); + + assert.equal(merged.unnamedRequestIds?.length, 2); +}); + +test('two windows over the same unnamed request report it once', () => { + const appLog = iosDump([REUSED_SUMMARY, SECOND_REUSED_SUMMARY]); + const recovery = iosDump([SECOND_REUSED_SUMMARY]); + + const merged = mergeNetworkDumps(recovery, appLog, 200); + + assert.equal(merged.unnamedRequestIds?.length, 2); +}); + +test('a request one window named is not still counted as unnamed from the other', () => { + const appLog = iosDump([REUSED_SUMMARY]); + const recovery = iosDump([CONNECTION_START, REUSED_SUMMARY]); + + assert.equal(appLog.unnamedRequestIds?.length, 1); + assert.equal(recovery.unnamedRequestIds?.length, 0); + + const merged = mergeNetworkDumps(recovery, appLog, 200); + + assert.deepEqual(merged.unnamedRequestIds, []); + assert.equal(merged.entries.filter((entry) => entry.pathUnavailable).length, 1); +}); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index db40051210..3391608fac 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -23,6 +23,9 @@ const METHOD_WITH_URL_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b\\s+h const URL_REGEX = /https?:\/\/[^\s"'<>\])]+/i; const CFNETWORK_CONNECTION_URL = /\[C(\d+)\b[^\]]*?\burl:\s*([^\s,\]]+)/; const CFNETWORK_TASK_SUMMARY = /\bsummary for task (?:success|failure)\s*\{([^}]*)\}/; +// `Task .` identifies one request across every line it appears on, +// so the same request seen in two scan windows reconciles to one. +const CFNETWORK_TASK_ID = /\bTask\s+<([0-9A-Fa-f-]+)>\.<(\d+)>/; // `name[pid:tid]` in the compact unified-log prefix. Connection numbers restart // per process, so a number alone would let a relaunched app inherit the origin // its predecessor opened; the pid is what keeps those apart. @@ -52,11 +55,22 @@ export function mergeNetworkDumps( entries.push(entry); if (entries.length >= maxEntries) break; } + // The two windows can cover different, overlapping, or disjoint traffic. A + // request either window named is named, and the rest union by identity, so + // neither window's blind spot inflates or masks the other's. + const named = new Set( + [...primary.entries, ...secondary.entries] + .map((entry) => entry.packetId) + .filter((id): id is string => id !== undefined), + ); + const unnamedRequestIds = [ + ...new Set([...(primary.unnamedRequestIds ?? []), ...(secondary.unnamedRequestIds ?? [])]), + ].filter((id) => !named.has(id)); return Object.freeze({ ...primary, matchedLines: entries.length, entries: Object.freeze(entries), - unnamedRequests: Math.max(primary.unnamedRequests ?? 0, secondary.unnamedRequests ?? 0), + unnamedRequestIds: Object.freeze(unnamedRequestIds), }); } @@ -76,7 +90,7 @@ export function readRecentNetworkTrafficFromText( scannedLines: 0, matchedLines: 0, entries: Object.freeze([]), - unnamedRequests: 0, + unnamedRequestIds: Object.freeze([]), include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); @@ -107,9 +121,9 @@ export function readRecentNetworkTrafficFromText( scannedLines: lines.length, matchedLines: entries.length, entries: Object.freeze(entries), - unnamedRequests: cfNetworkConnections - ? countUnnamedCfNetworkRequests(lines, cfNetworkConnections) - : 0, + unnamedRequestIds: Object.freeze( + cfNetworkConnections ? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections) : [], + ), include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); @@ -157,6 +171,7 @@ type NetworkIdentity = Readonly<{ status?: number; durationMs?: number; pathUnavailable?: boolean; + packetId?: string; }>; function parseNetworkIdentity( @@ -227,7 +242,7 @@ function createNetworkEntry( return { ...identity, timestamp: parseNetworkTimestamp(line), - packetId: parseAndroidPacketId(line) ?? undefined, + packetId: identity.packetId ?? parseAndroidPacketId(line) ?? undefined, durationMs: identity.durationMs ?? parseAndroidDurationMs(line) ?? undefined, raw: truncate(line, maxPayloadChars), line: lineNumber, @@ -334,26 +349,46 @@ function parseCfNetworkReusedTaskIdentity( status: readCfNetworkStatus(fields.get('response_status')), durationMs: readCfNetworkCount(fields.get('transaction_duration_ms')), pathUnavailable: true, + packetId: cfNetworkTaskId(line), }; } -function countUnnamedCfNetworkRequests( +function collectUnnamedCfNetworkTasks( lines: readonly string[], index: CfNetworkConnectionIndex, -): number { - let unnamed = 0; +): string[] { + const unnamed = new Set(); for (const [lineIndex, line] of lines.entries()) { - if (!line.includes('summary for task')) continue; - const summary = CFNETWORK_TASK_SUMMARY.exec(line); - if (!summary) continue; - const fields = readCfNetworkSummaryFields(summary[1] as string); - if (fields.get('reused') !== '1') continue; - const connection = fields.get('connection'); - const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); - if (key !== undefined && resolveCfNetworkOrigin(index, key, lineIndex)) continue; - unnamed += 1; + const task = unnamedCfNetworkTaskOn(line, index, lineIndex); + if (task !== undefined) unnamed.add(task); } - return unnamed; + return [...unnamed]; +} + +/** The identity of a reused task on this line that resolves to no origin. */ +function unnamedCfNetworkTaskOn( + line: string, + index: CfNetworkConnectionIndex, + lineIndex: number, +): string | undefined { + if (!line.includes('summary for task')) return undefined; + const summary = CFNETWORK_TASK_SUMMARY.exec(line); + if (!summary) return undefined; + const fields = readCfNetworkSummaryFields(summary[1] as string); + if (fields.get('reused') !== '1') return undefined; + const connection = fields.get('connection'); + const key = connection === undefined ? undefined : cfNetworkConnectionKey(line, connection); + if (key !== undefined && resolveCfNetworkOrigin(index, key, lineIndex)) return undefined; + return cfNetworkTaskId(line); +} + +/** One request's identity, scoped to its process so a relaunch cannot alias it. */ +function cfNetworkTaskId(line: string): string | undefined { + const task = CFNETWORK_TASK_ID.exec(line); + if (!task) return undefined; + const process = LOG_PROCESS_IDENTITY.exec(line); + const scope = process ? `${process[1]}[${process[2]}]` : ''; + return `${scope}#${task[1]}.${task[2]}`; } function resolveCfNetworkOrigin( diff --git a/packages/contracts/src/network-traffic.ts b/packages/contracts/src/network-traffic.ts index 388a6b6c13..b4040a46aa 100644 --- a/packages/contracts/src/network-traffic.ts +++ b/packages/contracts/src/network-traffic.ts @@ -21,11 +21,13 @@ export type NetworkDump = Readonly<{ matchedLines: number; entries: readonly NetworkEntry[]; /** - * Requests the reader observed but could not name at all, so they are absent - * from `entries`: an empty dump with a non-zero count is a failed capture, - * not evidence that nothing was requested. + * Identities of requests the reader observed but could not name at all, so + * they are absent from `entries`: an empty dump with a non-empty list is a + * failed capture, not evidence that nothing was requested. Identities rather + * than a count, so two scan windows over overlapping traffic reconcile to the + * requests actually seen instead of double-counting or under-reporting them. */ - unnamedRequests?: number; + unnamedRequestIds?: readonly string[]; include: NonNullable; limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>; }>; diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 8630ee7002..7b3ed4ecc2 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -250,7 +250,7 @@ test('a keep-alive request whose connection predates the window keeps the dump f if (result.source !== 'app-log') throw new Error('expected app-log result'); expect(result.dump.entries).toEqual([]); - expect(result.dump.unnamedRequests).toBe(1); + expect(result.dump.unnamedRequestIds).toHaveLength(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), @@ -273,7 +273,7 @@ test('simulator recovery keeps traffic it saw but could not name', async () => { if (result.source !== 'app-log') throw new Error('expected app-log result'); expect(result.dump.entries).toEqual([]); - expect(result.dump.unnamedRequests).toBe(1); + expect(result.dump.unnamedRequestIds).toHaveLength(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), @@ -295,7 +295,7 @@ test('recovery-only traffic that cannot be named is still reported, not called e ); if (result.source !== 'app-log') throw new Error('expected app-log result'); - expect(result.dump.unnamedRequests).toBe(1); + expect(result.dump.unnamedRequestIds).toHaveLength(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 8a66f841b0..d51d6a5f52 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -43,7 +43,7 @@ function mergeRecoveredTraffic( maxEntries: number, ): NetworkDump { const recovered = recovery.dump.entries.length; - if (recovered === 0 && (recovery.dump.unnamedRequests ?? 0) === 0) { + if (recovered === 0 && (recovery.dump.unnamedRequestIds ?? []).length === 0) { if (recovery.lineCount > 0) { notes.push( `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, @@ -139,7 +139,7 @@ function buildPredicate(appBundleId: string): string { */ function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void { const againstOrigin = dump.entries.filter((entry) => entry.pathUnavailable).length; - const unresolved = dump.unnamedRequests ?? 0; + const unresolved = (dump.unnamedRequestIds ?? []).length; const observed = againstOrigin + unresolved; if (observed === 0) return; const parts = [ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8bed29d648..db7c0bec82 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -991,7 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - iOS simulator log capture now streams from inside the simulator with `simctl spawn log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse. - iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. - On iOS, `network dump` can return zero HTTP entries for real app activity when the app does not emit request metadata into Unified Logging. The response notes now distinguish between an empty repro window and a non-network app log window. -- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are counted in `unnamedRequests` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. +- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are listed in `unnamedRequestIds` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. From c97ddb01a0b280e9118ad3115d9e53e8253f4857 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 10 Sep 2026 12:37:25 -0400 Subject: [PATCH 5/6] fix(network): keep unnamed-request identities out of the response `unnamedRequestIds` collected every unresolved task in the scan window and was spread straight into the response, so `network dump 1` could answer with thousands of task ids: an output whose size tracked the log rather than the requested entry limit. The identities exist to reconcile two scan windows, which is a step that finishes before a dump is returned. Keep them there. `NetworkDump` carries `unnamedRequests` as a count again, bounded by construction; the identities ride `ScannedNetworkDump`, the internal widening that the reader and the merge speak, and the Apple runtime projects them away with `withoutScanIdentities` on the way out. Reconciliation is unchanged: overlapping windows still collapse to one request and disjoint windows still sum, because the merge still sees the identities and recomputes the count from them. Regression: five unnameable tasks against `maxEntries: 1` reports all five and exposes no identity list. --- CHANGELOG.md | 8 ++--- packages/capture-kit/src/index.ts | 7 +++- .../capture-kit/src/network-traffic.test.ts | 22 ++++++------- packages/capture-kit/src/network-traffic.ts | 32 ++++++++++++++----- packages/contracts/src/network-traffic.ts | 12 +++---- .../src/network/runtime.test.ts | 30 +++++++++++++++-- .../platform-apple/src/network/runtime.ts | 26 ++++++++++----- website/docs/docs/commands.md | 2 +- 8 files changed, 97 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b1e026deb..36ee97535c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,10 @@ was called" check read as a definite fail. Such a request is now reported against the origin its connection was opened for, with `pathUnavailable` set, its status, and its timing. A reused request whose connection was opened before the scanned window cannot be named at all; those are - listed in the dump's `unnamedRequestIds`, so an empty result still reports that traffic was - observed. Identities rather than a count, so the app-log and recovery windows reconcile to the - requests actually seen instead of double-counting overlapping traffic or under-reporting - disjoint traffic. The notes say absence of an endpoint does not prove it was not called. + counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was + observed. The identities behind that count reconcile the app-log and recovery windows internally + — so overlapping traffic is not double-counted and disjoint traffic is not under-reported — but + the response carries only the count, which stays bounded however large the scan window was. The notes say absence of an endpoint does not prove it was not called. - Fixed: a URL logged as a delimited `url: ,` field no longer keeps the separator the log format put after it, so an entry's `url` compares equal to the endpoint under test. A bare URL elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 9248fc1546..7f9a04068c 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -29,4 +29,9 @@ export { appLogSessionArtifactsMatch, assertAppLogSessionArtifacts, } from './app-log-session-artifacts.ts'; -export { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; +export { + mergeNetworkDumps, + readRecentNetworkTrafficFromText, + withoutScanIdentities, + type ScannedNetworkDump, +} from './network-traffic.ts'; diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index f4e18b8131..70b754752b 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -65,7 +65,7 @@ test('keeps missing canonical app-log text distinct and merges recovery first', scannedLines: 0, matchedLines: 0, entries: [], - unnamedRequestIds: [], + unnamedRequests: 0, include: 'summary', limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, }); @@ -234,20 +234,20 @@ test('android dumps do not pay for CFNetwork correlation', () => { dump.entries.map((entry) => entry.url), ['http://localhost:3040/v4/messages/en_US'], ); - assert.equal(dump.unnamedRequestIds?.length, 0); + assert.equal(dump.unnamedRequests, 0); }); test('a reused request whose connection opened before the window is counted, not dropped', () => { const dump = iosDump([REUSED_SUMMARY]); assert.deepEqual(dump.entries, []); - assert.equal(dump.unnamedRequestIds?.length, 1); + assert.equal(dump.unnamedRequests, 1); }); test('a resolved reused request is named, not counted as unnamed', () => { const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); - assert.equal(dump.unnamedRequestIds?.length, 0); + assert.equal(dump.unnamedRequests, 0); assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1); }); @@ -268,7 +268,7 @@ test('a recycled connection number does not inherit the origin of a previous pro dump.entries.filter((entry) => entry.pathUnavailable), [], ); - assert.equal(dump.unnamedRequestIds?.length, 1); + assert.equal(dump.unnamedRequests, 1); }); test('a connection number is resolved within the process that opened it', () => { @@ -291,7 +291,7 @@ test('a line with no readable process identity leaves its traffic unnamed', () = dump.entries.filter((entry) => entry.pathUnavailable), [], ); - assert.equal(dump.unnamedRequestIds?.length, 1); + assert.equal(dump.unnamedRequests, 1); }); test('a URL whose path ends in punctuation is not truncated into a different endpoint', () => { @@ -320,7 +320,7 @@ test('two windows over disjoint unnamed traffic report both requests, not the la const merged = mergeNetworkDumps(recovery, appLog, 200); - assert.equal(merged.unnamedRequestIds?.length, 2); + assert.equal(merged.unnamedRequests, 2); }); test('two windows over the same unnamed request report it once', () => { @@ -329,18 +329,18 @@ test('two windows over the same unnamed request report it once', () => { const merged = mergeNetworkDumps(recovery, appLog, 200); - assert.equal(merged.unnamedRequestIds?.length, 2); + assert.equal(merged.unnamedRequests, 2); }); test('a request one window named is not still counted as unnamed from the other', () => { const appLog = iosDump([REUSED_SUMMARY]); const recovery = iosDump([CONNECTION_START, REUSED_SUMMARY]); - assert.equal(appLog.unnamedRequestIds?.length, 1); - assert.equal(recovery.unnamedRequestIds?.length, 0); + assert.equal(appLog.unnamedRequests, 1); + assert.equal(recovery.unnamedRequests, 0); const merged = mergeNetworkDumps(recovery, appLog, 200); - assert.deepEqual(merged.unnamedRequestIds, []); + assert.equal(merged.unnamedRequests, 0); assert.equal(merged.entries.filter((entry) => entry.pathUnavailable).length, 1); }); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 3391608fac..1e6ae446db 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -41,11 +41,24 @@ type CfNetworkConnectionIndex = ReadonlyMap< readonly Readonly<{ lineIndex: number; origin: string }>[] >; +/** + * A dump plus the identities behind its `unnamedRequests`. Reconciling two scan + * windows needs those identities; a caller returning a dump to its requester + * does not, and an unbounded list of them has no place in a response. + */ +export type ScannedNetworkDump = NetworkDump & Readonly<{ unnamedRequestIds?: readonly string[] }>; + +/** The public projection: identities dropped, their count kept. */ +export function withoutScanIdentities(dump: ScannedNetworkDump): NetworkDump { + const { unnamedRequestIds: _identities, ...rest } = dump; + return Object.freeze(rest); +} + export function mergeNetworkDumps( - primary: NetworkDump, - secondary: NetworkDump, + primary: ScannedNetworkDump, + secondary: ScannedNetworkDump, maxEntries = primary.limits.maxEntries, -): NetworkDump { +): ScannedNetworkDump { const entries = [...primary.entries]; const seen = new Set(entries.map(networkEntryKey)); for (const entry of secondary.entries) { @@ -70,6 +83,7 @@ export function mergeNetworkDumps( ...primary, matchedLines: entries.length, entries: Object.freeze(entries), + unnamedRequests: unnamedRequestIds.length, unnamedRequestIds: Object.freeze(unnamedRequestIds), }); } @@ -77,7 +91,7 @@ export function mergeNetworkDumps( export function readRecentNetworkTrafficFromText( content: string, options: NetworkDumpParserOptions, -): NetworkDump { +): ScannedNetworkDump { const maxEntries = clampInt(options.maxEntries, 25, 1, 200); const include = options.include ?? 'summary'; const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384); @@ -90,7 +104,7 @@ export function readRecentNetworkTrafficFromText( scannedLines: 0, matchedLines: 0, entries: Object.freeze([]), - unnamedRequestIds: Object.freeze([]), + unnamedRequests: 0, include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); @@ -102,6 +116,9 @@ export function readRecentNetworkTrafficFromText( const cfNetworkConnections = isAppleBackend(options.backend) ? indexCfNetworkConnections(lines) : undefined; + const unnamedRequestIds = cfNetworkConnections + ? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections) + : []; for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) { if (!lines[i]?.trim()) continue; const parsed = parseNetworkLine( @@ -121,9 +138,8 @@ export function readRecentNetworkTrafficFromText( scannedLines: lines.length, matchedLines: entries.length, entries: Object.freeze(entries), - unnamedRequestIds: Object.freeze( - cfNetworkConnections ? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections) : [], - ), + unnamedRequests: unnamedRequestIds.length, + unnamedRequestIds: Object.freeze(unnamedRequestIds), include, limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); diff --git a/packages/contracts/src/network-traffic.ts b/packages/contracts/src/network-traffic.ts index b4040a46aa..faf6c135c1 100644 --- a/packages/contracts/src/network-traffic.ts +++ b/packages/contracts/src/network-traffic.ts @@ -21,13 +21,13 @@ export type NetworkDump = Readonly<{ matchedLines: number; entries: readonly NetworkEntry[]; /** - * Identities of requests the reader observed but could not name at all, so - * they are absent from `entries`: an empty dump with a non-empty list is a - * failed capture, not evidence that nothing was requested. Identities rather - * than a count, so two scan windows over overlapping traffic reconcile to the - * requests actually seen instead of double-counting or under-reporting them. + * How many requests the reader observed but could not name at all, so they + * are absent from `entries`: an empty dump with a non-zero count is a failed + * capture, not evidence that nothing was requested. A count rather than the + * identities behind it, so the response stays bounded however many lines the + * scan window held. */ - unnamedRequestIds?: readonly string[]; + unnamedRequests?: number; include: NonNullable; limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>; }>; diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 7b3ed4ecc2..2a88f22125 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -250,7 +250,7 @@ test('a keep-alive request whose connection predates the window keeps the dump f if (result.source !== 'app-log') throw new Error('expected app-log result'); expect(result.dump.entries).toEqual([]); - expect(result.dump.unnamedRequestIds).toHaveLength(1); + expect(result.dump.unnamedRequests).toBe(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), @@ -273,7 +273,7 @@ test('simulator recovery keeps traffic it saw but could not name', async () => { if (result.source !== 'app-log') throw new Error('expected app-log result'); expect(result.dump.entries).toEqual([]); - expect(result.dump.unnamedRequestIds).toHaveLength(1); + expect(result.dump.unnamedRequests).toBe(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), @@ -295,10 +295,34 @@ test('recovery-only traffic that cannot be named is still reported, not called e ); if (result.source !== 'app-log') throw new Error('expected app-log result'); - expect(result.dump.unnamedRequestIds).toHaveLength(1); + expect(result.dump.unnamedRequests).toBe(1); expect(result.notes).toEqual([ expect.stringContaining('1 opened before this scan window'), expect.stringContaining('No HTTP(s) entries were found'), ]); expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic'); }); + +test('a response bounded to one entry still reports every unnamed request, without their ids', async () => { + // Five reused tasks, none resolvable: far more than the requested entry limit. + const summaries = Array.from({ length: 5 }, (_, index) => + REUSED_SUMMARY.replace('Task <2FAEF670>.<2>', `Task <2FAEF670>.<${index + 10}>`), + ); + const result = await dumpAppleNetworkTraffic( + host({ + text: `${summaries.join('\n')}\n`, + runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })), + }), + simulator, + input({ maxEntries: 1, appLogSnapshot: { state: 'active', startedAt: 1_000 } }), + new AbortController().signal, + ); + + if (result.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.entries).toEqual([]); + expect(result.dump.unnamedRequests).toBe(5); + // The identities are a reconciliation detail and must not reach the response, + // where their number is bounded by the scan window rather than by maxEntries. + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); + expect(result.notes[0]).toContain('5 requests reused a keep-alive connection'); +}); diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index d51d6a5f52..178278ebe2 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -1,7 +1,12 @@ import type { NetworkDump } from '@agent-device/contracts/network-traffic'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { + mergeNetworkDumps, + readRecentNetworkTrafficFromText, + withoutScanIdentities, + type ScannedNetworkDump, +} from '@agent-device/capture-kit'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { backendForAppleDevice } from '../logs/backend.ts'; @@ -28,7 +33,12 @@ export async function dumpAppleNetworkTraffic( appendLifecycleNote(notes, device, input); appendUnnamedRequestNote(notes, dump); if (dump.entries.length === 0) notes.push(noEntriesNote(device)); - return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) }); + return Object.freeze({ + source: 'app-log', + backend, + dump: withoutScanIdentities(dump), + notes: Object.freeze(notes), + }); } /** @@ -38,12 +48,12 @@ export async function dumpAppleNetworkTraffic( */ function mergeRecoveredTraffic( notes: string[], - dump: NetworkDump, - recovery: { dump: NetworkDump; lineCount: number }, + dump: ScannedNetworkDump, + recovery: { dump: ScannedNetworkDump; lineCount: number }, maxEntries: number, -): NetworkDump { +): ScannedNetworkDump { const recovered = recovery.dump.entries.length; - if (recovered === 0 && (recovery.dump.unnamedRequestIds ?? []).length === 0) { + if (recovered === 0 && (recovery.dump.unnamedRequests ?? 0) === 0) { if (recovery.lineCount > 0) { notes.push( `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, @@ -78,7 +88,7 @@ async function recoverSimulatorTraffic( input: NetworkDumpInput, appLogPath: string, signal: AbortSignal, -): Promise<{ dump: NetworkDump; lineCount: number } | undefined> { +): Promise<{ dump: ScannedNetworkDump; lineCount: number } | undefined> { const args = [ ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), 'spawn', @@ -139,7 +149,7 @@ function buildPredicate(appBundleId: string): string { */ function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void { const againstOrigin = dump.entries.filter((entry) => entry.pathUnavailable).length; - const unresolved = (dump.unnamedRequestIds ?? []).length; + const unresolved = dump.unnamedRequests ?? 0; const observed = againstOrigin + unresolved; if (observed === 0) return; const parts = [ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index db7c0bec82..8bed29d648 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -991,7 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests - iOS simulator log capture now streams from inside the simulator with `simctl spawn log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse. - iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime. - On iOS, `network dump` can return zero HTTP entries for real app activity when the app does not emit request metadata into Unified Logging. The response notes now distinguish between an empty repro window and a non-network app log window. -- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are listed in `unnamedRequestIds` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. +- On iOS, CFNetwork logs a request URL only on the line that opens a connection, so a request that reused a keep-alive connection has no URL anywhere in the log. Those requests are reported against the origin their connection was opened for, with `pathUnavailable: true`, their status, and their timing; ones whose connection was opened before the scanned window are counted in `unnamedRequests` instead, since they cannot be named at all. Treat a missing endpoint in an iOS dump as unproven rather than as evidence it was not called. - Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits. - Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list. From 3fd644133d0631a0353a03264564dfddacfe5f72 Mon Sep 17 00:00:00 2001 From: Brad Anderson Date: Thu, 10 Sep 2026 13:16:16 -0400 Subject: [PATCH 6/6] fix(network): return scan identities beside the dump, not on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apple route stopped leaking task identities into its response, but Limrun and WebDriver return the scanner result directly and both serve Apple sessions, so an iOS `network dump 1` through either still answered with every unresolved task id in the scan window. Projecting at one producer was never going to hold: `ScannedNetworkDump` was assignable to `NetworkDump`, so returning the scanner result compiled everywhere and each producer had to remember not to. Take the shape away instead. `readRecentNetworkTrafficFromText` returns a `NetworkScan` — `{ dump, unnamedRequestIds }` — so identities sit beside the public dump rather than on it, and `mergeNetworkScans` reconciles the pair. A route returning `scan.dump` cannot carry them out, and a route that forgets does not compile. All four producers are updated; the response shape is unchanged. Regressions cover the Apple, Limrun and WebDriver routes: five unnameable tasks against `maxEntries: 1` report the count and expose no identity list. All three fail if the identities are put back on the dump. --- packages/capture-kit/src/index.ts | 5 +- .../src/network-traffic-android.test.ts | 2 +- .../capture-kit/src/network-traffic.test.ts | 54 +++++------ packages/capture-kit/src/network-traffic.ts | 89 ++++++++++--------- .../platform-android/src/network/runtime.ts | 14 +-- .../platform-apple/src/network/runtime.ts | 35 ++++---- .../src/app-log-runtime.test.ts | 40 +++++++++ .../provider-limrun/src/app-log-runtime.ts | 2 +- .../src/platform-runtime.test.ts | 50 +++++++++++ .../src/platform-runtime.ts | 2 +- src/platform-runtime-network-host.test.ts | 2 +- 11 files changed, 198 insertions(+), 97 deletions(-) diff --git a/packages/capture-kit/src/index.ts b/packages/capture-kit/src/index.ts index 7f9a04068c..f83799095b 100644 --- a/packages/capture-kit/src/index.ts +++ b/packages/capture-kit/src/index.ts @@ -30,8 +30,7 @@ export { assertAppLogSessionArtifacts, } from './app-log-session-artifacts.ts'; export { - mergeNetworkDumps, + mergeNetworkScans, readRecentNetworkTrafficFromText, - withoutScanIdentities, - type ScannedNetworkDump, + type NetworkScan, } from './network-traffic.ts'; diff --git a/packages/capture-kit/src/network-traffic-android.test.ts b/packages/capture-kit/src/network-traffic-android.test.ts index 79617c4292..ff7af97815 100644 --- a/packages/capture-kit/src/network-traffic-android.test.ts +++ b/packages/capture-kit/src/network-traffic-android.test.ts @@ -3,7 +3,7 @@ import { test } from 'vitest'; import { readRecentNetworkTrafficFromText } from './network-traffic.ts'; test('preserves Android adjacent packet enrichment', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1', '03-31 17:43:32.700 V/OtherTag (17434): unrelated line 1', diff --git a/packages/capture-kit/src/network-traffic.test.ts b/packages/capture-kit/src/network-traffic.test.ts index 70b754752b..48e9cc93db 100644 --- a/packages/capture-kit/src/network-traffic.test.ts +++ b/packages/capture-kit/src/network-traffic.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts'; +import { mergeNetworkScans, readRecentNetworkTrafficFromText } from './network-traffic.ts'; test('parses the existing include projections and newest-first order', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '2026-02-24T10:00:00Z GET https://api.example.com/profile status=200', '2026-02-24T10:00:02Z {"method":"POST","url":"https://api.example.com/login","statusCode":401,"headers":{"x-id":"abc"},"requestBody":{"email":"u@example.com"},"responseBody":{"error":"denied"}}', @@ -35,7 +35,7 @@ test('parses the existing include projections and newest-first order', () => { }); test('keeps missing canonical app-log text distinct and merges recovery first', () => { - const missing = readRecentNetworkTrafficFromText('', { + const { dump: missing } = readRecentNetworkTrafficFromText('', { path: '/sessions/one/app.log', exists: false, backend: 'android', @@ -70,13 +70,13 @@ test('keeps missing canonical app-log text distinct and merges recovery first', limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 }, }); assert.deepEqual( - mergeNetworkDumps(recovered, stale, 2).entries.map(({ url }) => url), + mergeNetworkScans(recovered, stale, 2).dump.entries.map(({ url }) => url), ['https://fresh.example.test', 'https://stale.example.test'], ); }); test('keeps Android adjacent enrichment disabled for Apple backends', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( [ '2026-03-31 17:43:33.031 response code: 200', '2026-03-31 17:43:33.032 URL: https://api.example.com/fixture', @@ -97,7 +97,7 @@ test('keeps Android adjacent enrichment disabled for Apple backends', () => { }); test('ignores documentation URLs without an explicit network signal', () => { - const dump = readRecentNetworkTrafficFromText( + const { dump } = readRecentNetworkTrafficFromText( '2026-04-02 08:14:44Z config warning. See https://docs.example.test/setup for help.\n', { path: '/sessions/one/app.log', @@ -124,7 +124,7 @@ test('applies a validated absolute line offset to host-selected text', () => { maxScanLines: 100, }; - const dump = readRecentNetworkTrafficFromText('GET https://example.test status=200', { + const { dump } = readRecentNetworkTrafficFromText('GET https://example.test status=200', { ...options, lineNumberOffset: 5000, }); @@ -142,7 +142,7 @@ test('applies a validated absolute line offset to host-selected text', () => { test('a URL logged mid-sentence drops the separator that follows it', () => { const line = '2026-09-09 18:22:27.805 Df spicygolf[33656:4505afd] [com.apple.network:connection] [C9 Hostname#c6f77afc:3040 tcp, url: http://localhost:3040/v4/messages/en_US, definite, attribution: developer] start'; - const dump = readRecentNetworkTrafficFromText(`${line}\n`, { + const { dump } = readRecentNetworkTrafficFromText(`${line}\n`, { path: 'app.log', exists: true, backend: 'ios-simulator', @@ -161,7 +161,7 @@ const OPENING_SUMMARY = const REUSED_SUMMARY = '2026-09-09 18:22:28.167 Df spicygolf[33656:4505ae4] [com.apple.CFNetwork:Summary] Task <2FAEF670-BB27-42A4-ACDD-6B6DF7D11510>.<2> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1, reused_after_ms=0, request_bytes=236, response_bytes=624, cache_hit=true}'; -function iosDump(lines: readonly string[]) { +function iosScan(lines: readonly string[]) { return readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { path: 'app.log', exists: true, @@ -169,6 +169,10 @@ function iosDump(lines: readonly string[]) { }); } +function iosDump(lines: readonly string[]) { + return iosScan(lines).dump; +} + test('a request that reused a keep-alive connection is reported against its origin', () => { const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]); const reused = dump.entries.find((entry) => entry.pathUnavailable); @@ -224,7 +228,7 @@ test('android dumps do not pay for CFNetwork correlation', () => { const lines = `${[CONNECTION_START, REUSED_SUMMARY].join('\n')}\n`; assert.equal(iosDump([CONNECTION_START, REUSED_SUMMARY]).entries.length, 2); - const dump = readRecentNetworkTrafficFromText(lines, { + const { dump } = readRecentNetworkTrafficFromText(lines, { path: 'app.log', exists: true, backend: 'android', @@ -315,32 +319,32 @@ const SECOND_REUSED_SUMMARY = REUSED_SUMMARY.replace( ); test('two windows over disjoint unnamed traffic report both requests, not the larger count', () => { - const appLog = iosDump([REUSED_SUMMARY]); - const recovery = iosDump([SECOND_REUSED_SUMMARY]); + const appLog = iosScan([REUSED_SUMMARY]); + const recovery = iosScan([SECOND_REUSED_SUMMARY]); - const merged = mergeNetworkDumps(recovery, appLog, 200); + const merged = mergeNetworkScans(recovery, appLog, 200); - assert.equal(merged.unnamedRequests, 2); + assert.equal(merged.dump.unnamedRequests, 2); }); test('two windows over the same unnamed request report it once', () => { - const appLog = iosDump([REUSED_SUMMARY, SECOND_REUSED_SUMMARY]); - const recovery = iosDump([SECOND_REUSED_SUMMARY]); + const appLog = iosScan([REUSED_SUMMARY, SECOND_REUSED_SUMMARY]); + const recovery = iosScan([SECOND_REUSED_SUMMARY]); - const merged = mergeNetworkDumps(recovery, appLog, 200); + const merged = mergeNetworkScans(recovery, appLog, 200); - assert.equal(merged.unnamedRequests, 2); + assert.equal(merged.dump.unnamedRequests, 2); }); test('a request one window named is not still counted as unnamed from the other', () => { - const appLog = iosDump([REUSED_SUMMARY]); - const recovery = iosDump([CONNECTION_START, REUSED_SUMMARY]); + const appLog = iosScan([REUSED_SUMMARY]); + const recovery = iosScan([CONNECTION_START, REUSED_SUMMARY]); - assert.equal(appLog.unnamedRequests, 1); - assert.equal(recovery.unnamedRequests, 0); + assert.equal(appLog.dump.unnamedRequests, 1); + assert.equal(recovery.dump.unnamedRequests, 0); - const merged = mergeNetworkDumps(recovery, appLog, 200); + const merged = mergeNetworkScans(recovery, appLog, 200); - assert.equal(merged.unnamedRequests, 0); - assert.equal(merged.entries.filter((entry) => entry.pathUnavailable).length, 1); + assert.equal(merged.dump.unnamedRequests, 0); + assert.equal(merged.dump.entries.filter((entry) => entry.pathUnavailable).length, 1); }); diff --git a/packages/capture-kit/src/network-traffic.ts b/packages/capture-kit/src/network-traffic.ts index 1e6ae446db..197b7a156d 100644 --- a/packages/capture-kit/src/network-traffic.ts +++ b/packages/capture-kit/src/network-traffic.ts @@ -42,26 +42,28 @@ type CfNetworkConnectionIndex = ReadonlyMap< >; /** - * A dump plus the identities behind its `unnamedRequests`. Reconciling two scan - * windows needs those identities; a caller returning a dump to its requester - * does not, and an unbounded list of them has no place in a response. + * A scan's public dump, and the identities behind its `unnamedRequests`. + * + * The identities exist to reconcile two scan windows and have no place in a + * response, where their number tracks the log rather than the caller's entry + * limit. They sit beside the dump rather than on it so that a route returning + * `scan.dump` cannot carry them out by accident: every producer of a dump is a + * response boundary, and this is the one shape that does not rely on each of + * them remembering. */ -export type ScannedNetworkDump = NetworkDump & Readonly<{ unnamedRequestIds?: readonly string[] }>; - -/** The public projection: identities dropped, their count kept. */ -export function withoutScanIdentities(dump: ScannedNetworkDump): NetworkDump { - const { unnamedRequestIds: _identities, ...rest } = dump; - return Object.freeze(rest); -} +export type NetworkScan = Readonly<{ + dump: NetworkDump; + unnamedRequestIds: readonly string[]; +}>; -export function mergeNetworkDumps( - primary: ScannedNetworkDump, - secondary: ScannedNetworkDump, - maxEntries = primary.limits.maxEntries, -): ScannedNetworkDump { - const entries = [...primary.entries]; +export function mergeNetworkScans( + primary: NetworkScan, + secondary: NetworkScan, + maxEntries = primary.dump.limits.maxEntries, +): NetworkScan { + const entries = [...primary.dump.entries]; const seen = new Set(entries.map(networkEntryKey)); - for (const entry of secondary.entries) { + for (const entry of secondary.dump.entries) { const key = networkEntryKey(entry); if (seen.has(key)) continue; seen.add(key); @@ -72,18 +74,20 @@ export function mergeNetworkDumps( // request either window named is named, and the rest union by identity, so // neither window's blind spot inflates or masks the other's. const named = new Set( - [...primary.entries, ...secondary.entries] + [...primary.dump.entries, ...secondary.dump.entries] .map((entry) => entry.packetId) .filter((id): id is string => id !== undefined), ); const unnamedRequestIds = [ - ...new Set([...(primary.unnamedRequestIds ?? []), ...(secondary.unnamedRequestIds ?? [])]), + ...new Set([...primary.unnamedRequestIds, ...secondary.unnamedRequestIds]), ].filter((id) => !named.has(id)); return Object.freeze({ - ...primary, - matchedLines: entries.length, - entries: Object.freeze(entries), - unnamedRequests: unnamedRequestIds.length, + dump: Object.freeze({ + ...primary.dump, + matchedLines: entries.length, + entries: Object.freeze(entries), + unnamedRequests: unnamedRequestIds.length, + }), unnamedRequestIds: Object.freeze(unnamedRequestIds), }); } @@ -91,7 +95,7 @@ export function mergeNetworkDumps( export function readRecentNetworkTrafficFromText( content: string, options: NetworkDumpParserOptions, -): ScannedNetworkDump { +): NetworkScan { const maxEntries = clampInt(options.maxEntries, 25, 1, 200); const include = options.include ?? 'summary'; const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384); @@ -99,14 +103,17 @@ export function readRecentNetworkTrafficFromText( const lineNumberOffset = requireLineNumberOffset(options.lineNumberOffset); if (!options.exists) { return Object.freeze({ - path: options.path, - exists: false, - scannedLines: 0, - matchedLines: 0, - entries: Object.freeze([]), - unnamedRequests: 0, - include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + dump: Object.freeze({ + path: options.path, + exists: false, + scannedLines: 0, + matchedLines: 0, + entries: Object.freeze([]), + unnamedRequests: 0, + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }), + unnamedRequestIds: Object.freeze([]), }); } const allLines = content.split('\n'); @@ -133,15 +140,17 @@ export function readRecentNetworkTrafficFromText( if (parsed) entries.push(parsed); } return Object.freeze({ - path: options.path, - exists: true, - scannedLines: lines.length, - matchedLines: entries.length, - entries: Object.freeze(entries), - unnamedRequests: unnamedRequestIds.length, + dump: Object.freeze({ + path: options.path, + exists: true, + scannedLines: lines.length, + matchedLines: entries.length, + entries: Object.freeze(entries), + unnamedRequests: unnamedRequestIds.length, + include, + limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), + }), unnamedRequestIds: Object.freeze(unnamedRequestIds), - include, - limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }), }); } diff --git a/packages/platform-android/src/network/runtime.ts b/packages/platform-android/src/network/runtime.ts index 3799e53755..c6373fe05b 100644 --- a/packages/platform-android/src/network/runtime.ts +++ b/packages/platform-android/src/network/runtime.ts @@ -4,7 +4,7 @@ import type { } from '@agent-device/contracts/platform-runtime-host'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; -import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; +import { mergeNetworkScans, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { assertAndroidLogPackageSafe } from '../logs/package-name.ts'; @@ -20,7 +20,7 @@ export async function dumpAndroidNetworkTraffic( signal: AbortSignal, ): Promise { const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let dump = readRecentNetworkTrafficFromText(recent.text, { + let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, @@ -33,14 +33,14 @@ export async function dumpAndroidNetworkTraffic( assertAndroidLogPackageSafe(input.appBundleId); const recovered = await recoverPackageTraffic(host, device, input.appBundleId, signal); if (recovered) { - const recoveryDump = readRecentNetworkTrafficFromText(recovered.text, { + const recoveryScan = readRecentNetworkTrafficFromText(recovered.text, { ...input, path: `${recent.path} (adb logcat recovery)`, exists: true, backend: 'android', }); - if (recoveryDump.entries.length > 0) { - dump = mergeNetworkDumps(recoveryDump, dump, input.maxEntries); + if (recoveryScan.dump.entries.length > 0) { + scan = mergeNetworkScans(recoveryScan, scan, input.maxEntries); notes.push( context.reason === 'stale-active' ? `Session app log stream was still bound to prior Android PID ${context.trackedPid}. Recovered recent Android HTTP entries from adb logcat for PID set ${recovered.pids.join(', ')}.` @@ -58,13 +58,13 @@ export async function dumpAndroidNetworkTraffic( 'Session app log stream is inactive. Run logs clear --restart, reproduce the request window again, then rerun network dump.', ); } - if (dump.entries.length === 0) { + if (scan.dump.entries.length === 0) { notes.push('No HTTP(s) entries were found in recent session app logs.'); } return Object.freeze({ source: 'app-log', backend: 'android', - dump, + dump: scan.dump, notes: Object.freeze(notes), }); } diff --git a/packages/platform-apple/src/network/runtime.ts b/packages/platform-apple/src/network/runtime.ts index 178278ebe2..a1f3b0a2a9 100644 --- a/packages/platform-apple/src/network/runtime.ts +++ b/packages/platform-apple/src/network/runtime.ts @@ -2,10 +2,9 @@ import type { NetworkDump } from '@agent-device/contracts/network-traffic'; import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime'; import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations'; import { - mergeNetworkDumps, + mergeNetworkScans, readRecentNetworkTrafficFromText, - withoutScanIdentities, - type ScannedNetworkDump, + type NetworkScan, } from '@agent-device/capture-kit'; import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { backendForAppleDevice } from '../logs/backend.ts'; @@ -18,7 +17,7 @@ export async function dumpAppleNetworkTraffic( ): Promise { const backend = backendForAppleDevice(device); const recent = await host.appLogs.readRecent(input.sessionId, input.maxScanLines); - let dump = readRecentNetworkTrafficFromText(recent.text, { + let scan = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, @@ -26,17 +25,17 @@ export async function dumpAppleNetworkTraffic( backend, }); const notes: string[] = []; - if (canRecoverSimulator(device, input, dump)) { + if (canRecoverSimulator(device, input, scan.dump)) { const recovery = await recoverSimulatorTraffic(host, device, input, recent.path, signal); - if (recovery) dump = mergeRecoveredTraffic(notes, dump, recovery, input.maxEntries); + if (recovery) scan = mergeRecoveredTraffic(notes, scan, recovery, input.maxEntries); } appendLifecycleNote(notes, device, input); - appendUnnamedRequestNote(notes, dump); - if (dump.entries.length === 0) notes.push(noEntriesNote(device)); + appendUnnamedRequestNote(notes, scan.dump); + if (scan.dump.entries.length === 0) notes.push(noEntriesNote(device)); return Object.freeze({ source: 'app-log', backend, - dump: withoutScanIdentities(dump), + dump: scan.dump, notes: Object.freeze(notes), }); } @@ -48,25 +47,25 @@ export async function dumpAppleNetworkTraffic( */ function mergeRecoveredTraffic( notes: string[], - dump: ScannedNetworkDump, - recovery: { dump: ScannedNetworkDump; lineCount: number }, + scan: NetworkScan, + recovery: { scan: NetworkScan; lineCount: number }, maxEntries: number, -): ScannedNetworkDump { - const recovered = recovery.dump.entries.length; - if (recovered === 0 && (recovery.dump.unnamedRequests ?? 0) === 0) { +): NetworkScan { + const recovered = recovery.scan.dump.entries.length; + if (recovered === 0 && (recovery.scan.dump.unnamedRequests ?? 0) === 0) { if (recovery.lineCount > 0) { notes.push( `Recovered ${recovery.lineCount} recent iOS simulator app log lines from simctl log show, but none looked like HTTP traffic. This app may not emit request URLs, status, or timing into Unified Logging for this repro window.`, ); } - return dump; + return scan; } if (recovered > 0) { notes.push( `Recovered ${recovered} iOS simulator HTTP entr${recovered === 1 ? 'y' : 'ies'} from simctl log show (${recovery.lineCount} app log lines scanned).`, ); } - return mergeNetworkDumps(recovery.dump, dump, maxEntries); + return mergeNetworkScans(recovery.scan, scan, maxEntries); } function canRecoverSimulator( @@ -88,7 +87,7 @@ async function recoverSimulatorTraffic( input: NetworkDumpInput, appLogPath: string, signal: AbortSignal, -): Promise<{ dump: ScannedNetworkDump; lineCount: number } | undefined> { +): Promise<{ scan: NetworkScan; lineCount: number } | undefined> { const args = [ ...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []), 'spawn', @@ -121,7 +120,7 @@ async function recoverSimulatorTraffic( ); if (lines.length === 0) return undefined; return { - dump: readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { + scan: readRecentNetworkTrafficFromText(`${lines.join('\n')}\n`, { ...input, path: `${appLogPath} (simctl log show recovery)`, exists: true, diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 5b450e85d3..a717238cab 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -494,3 +494,43 @@ test('closes every Limrun gesture and scroll cell without a live session', async }); } }); + +test('an iOS limrun dump bounded to one entry reports unnamed traffic without its identities', async () => { + // Five keep-alive requests CFNetwork logged no URL for, and no connection + // line to resolve them against: many more unnamed tasks than maxEntries. + const summaries = Array.from( + { length: 5 }, + (_, index) => + `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, + ); + const base = unusedHost(); + const owner = createLimrunPlatformRuntimeOwner( + limrunOwnerOptions({ + host: { + ...base, + appLogs: { + ...base.appLogs, + readRecent: async () => ({ + path: '/sessions/session/app.log', + exists: true, + text: `${summaries.join('\n')}\n`, + skippedLines: 0, + }), + }, + }, + }), + ); + const binding = await owner.bind({ device, intent: { kind: 'ordinary' }, scope }); + + const result = await binding.operations.networkDump?.({ + sessionId: 'session', + maxEntries: 1, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }); + + if (result?.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.dump.unnamedRequests).toBe(5); + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); +}); diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index e3863fd286..8c0c915b2f 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -252,7 +252,7 @@ function bindLimrunAppLogs( networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); const backend = backendForDevice(device); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 81638b314e..51fd9f90d8 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -612,3 +612,53 @@ test('a reachable provider with no overrides still admits its declared operation expect(facts.operations[key].available).toBe(true); } }); + +test('an Apple WebDriver dump bounded to one entry reports unnamed traffic without its identities', async () => { + // Five keep-alive requests CFNetwork logged no URL for, and no connection + // line to resolve them against: many more unnamed tasks than maxEntries. + const summaries = Array.from( + { length: 5 }, + (_, index) => + `2026-09-09 18:22:28.167 Df app[1:2] [com.apple.CFNetwork:Summary] Task <2FAEF670>.<${index + 10}> summary for task success {transaction_duration_ms=1, response_status=200, connection=9, reused=1}`, + ); + const base = host(vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))); + const owner = createWebDriverPlatformRuntimeOwner({ + host: { + ...base, + appLogs: { + ...base.appLogs, + readRecent: async () => ({ + path: '/sessions/one/app.log', + exists: true, + text: `${summaries.join('\n')}\n`, + skippedLines: 0, + }), + }, + }, + owner: providerRuntimeOwner('browserstack', 'apple'), + ownsDevice: () => true, + capabilities: capabilities(), + }); + const binding = await owner.bind({ + device: { ...device, platform: 'apple', appleOs: 'ios', id: 'browserstack:lease-ios' }, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + + const result = await binding.operations.networkDump?.({ + sessionId: 'one', + maxEntries: 1, + include: 'summary', + maxPayloadChars: 2048, + maxScanLines: 4000, + }); + + if (result?.source !== 'app-log') throw new Error('expected app-log result'); + expect(result.backend).toBe('ios-device'); + expect(result.dump.unnamedRequests).toBe(5); + expect(result.dump).not.toHaveProperty('unnamedRequestIds'); +}); diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index c011a140d2..664b53fe81 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -424,7 +424,7 @@ function bindWebDriverPlatformRuntime( ...webDriverInteractionOperations(options, device, signal, facts), networkDump: async (input) => { const recent = await options.host.appLogs.readRecent(input.sessionId, input.maxScanLines); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { ...input, path: recent.path, exists: recent.exists, diff --git a/src/platform-runtime-network-host.test.ts b/src/platform-runtime-network-host.test.ts index 5d6214b7a5..7de96b09bc 100644 --- a/src/platform-runtime-network-host.test.ts +++ b/src/platform-runtime-network-host.test.ts @@ -54,7 +54,7 @@ test('preserves absolute source line numbers after selecting a bounded suffix', fs.writeFileSync(pathname, `${text}\n`); const recent = readRecentAppLogLines(pathname, 4000); - const dump = readRecentNetworkTrafficFromText(recent.text, { + const { dump } = readRecentNetworkTrafficFromText(recent.text, { path: recent.path, exists: recent.exists, backend: 'android',