Skip to content

Commit a46bcd5

Browse files
committed
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.
1 parent eef183c commit a46bcd5

8 files changed

Lines changed: 97 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
was called" check read as a definite fail. Such a request is now reported against the origin its
2020
connection was opened for, with `pathUnavailable` set, its status, and its timing. A reused
2121
request whose connection was opened before the scanned window cannot be named at all; those are
22-
listed in the dump's `unnamedRequestIds`, so an empty result still reports that traffic was
23-
observed. Identities rather than a count, so the app-log and recovery windows reconcile to the
24-
requests actually seen instead of double-counting overlapping traffic or under-reporting
25-
disjoint traffic. The notes say absence of an endpoint does not prove it was not called.
22+
counted in the dump's `unnamedRequests`, so an empty result still reports that traffic was
23+
observed. The identities behind that count reconcile the app-log and recovery windows internally
24+
— so overlapping traffic is not double-counted and disjoint traffic is not under-reported — but
25+
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.
2626
- Fixed: a URL logged as a delimited `url: <value>,` field no longer keeps the separator the log
2727
format put after it, so an entry's `url` compares equal to the endpoint under test. A bare URL
2828
elsewhere is left alone, since nothing there establishes that trailing punctuation is not part of

packages/capture-kit/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ export {
2929
appLogSessionArtifactsMatch,
3030
assertAppLogSessionArtifacts,
3131
} from './app-log-session-artifacts.ts';
32-
export { mergeNetworkDumps, readRecentNetworkTrafficFromText } from './network-traffic.ts';
32+
export {
33+
mergeNetworkDumps,
34+
readRecentNetworkTrafficFromText,
35+
withoutScanIdentities,
36+
type ScannedNetworkDump,
37+
} from './network-traffic.ts';

packages/capture-kit/src/network-traffic.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ test('keeps missing canonical app-log text distinct and merges recovery first',
6565
scannedLines: 0,
6666
matchedLines: 0,
6767
entries: [],
68-
unnamedRequestIds: [],
68+
unnamedRequests: 0,
6969
include: 'summary',
7070
limits: { maxEntries: 2, maxPayloadChars: 2048, maxScanLines: 100 },
7171
});
@@ -234,20 +234,20 @@ test('android dumps do not pay for CFNetwork correlation', () => {
234234
dump.entries.map((entry) => entry.url),
235235
['http://localhost:3040/v4/messages/en_US'],
236236
);
237-
assert.equal(dump.unnamedRequestIds?.length, 0);
237+
assert.equal(dump.unnamedRequests, 0);
238238
});
239239

240240
test('a reused request whose connection opened before the window is counted, not dropped', () => {
241241
const dump = iosDump([REUSED_SUMMARY]);
242242

243243
assert.deepEqual(dump.entries, []);
244-
assert.equal(dump.unnamedRequestIds?.length, 1);
244+
assert.equal(dump.unnamedRequests, 1);
245245
});
246246

247247
test('a resolved reused request is named, not counted as unnamed', () => {
248248
const dump = iosDump([CONNECTION_START, OPENING_SUMMARY, REUSED_SUMMARY]);
249249

250-
assert.equal(dump.unnamedRequestIds?.length, 0);
250+
assert.equal(dump.unnamedRequests, 0);
251251
assert.equal(dump.entries.filter((entry) => entry.pathUnavailable).length, 1);
252252
});
253253

@@ -268,7 +268,7 @@ test('a recycled connection number does not inherit the origin of a previous pro
268268
dump.entries.filter((entry) => entry.pathUnavailable),
269269
[],
270270
);
271-
assert.equal(dump.unnamedRequestIds?.length, 1);
271+
assert.equal(dump.unnamedRequests, 1);
272272
});
273273

274274
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', () =
291291
dump.entries.filter((entry) => entry.pathUnavailable),
292292
[],
293293
);
294-
assert.equal(dump.unnamedRequestIds?.length, 1);
294+
assert.equal(dump.unnamedRequests, 1);
295295
});
296296

297297
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
320320

321321
const merged = mergeNetworkDumps(recovery, appLog, 200);
322322

323-
assert.equal(merged.unnamedRequestIds?.length, 2);
323+
assert.equal(merged.unnamedRequests, 2);
324324
});
325325

326326
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', () => {
329329

330330
const merged = mergeNetworkDumps(recovery, appLog, 200);
331331

332-
assert.equal(merged.unnamedRequestIds?.length, 2);
332+
assert.equal(merged.unnamedRequests, 2);
333333
});
334334

335335
test('a request one window named is not still counted as unnamed from the other', () => {
336336
const appLog = iosDump([REUSED_SUMMARY]);
337337
const recovery = iosDump([CONNECTION_START, REUSED_SUMMARY]);
338338

339-
assert.equal(appLog.unnamedRequestIds?.length, 1);
340-
assert.equal(recovery.unnamedRequestIds?.length, 0);
339+
assert.equal(appLog.unnamedRequests, 1);
340+
assert.equal(recovery.unnamedRequests, 0);
341341

342342
const merged = mergeNetworkDumps(recovery, appLog, 200);
343343

344-
assert.deepEqual(merged.unnamedRequestIds, []);
344+
assert.equal(merged.unnamedRequests, 0);
345345
assert.equal(merged.entries.filter((entry) => entry.pathUnavailable).length, 1);
346346
});

packages/capture-kit/src/network-traffic.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,24 @@ type CfNetworkConnectionIndex = ReadonlyMap<
4141
readonly Readonly<{ lineIndex: number; origin: string }>[]
4242
>;
4343

44+
/**
45+
* A dump plus the identities behind its `unnamedRequests`. Reconciling two scan
46+
* windows needs those identities; a caller returning a dump to its requester
47+
* does not, and an unbounded list of them has no place in a response.
48+
*/
49+
export type ScannedNetworkDump = NetworkDump & Readonly<{ unnamedRequestIds?: readonly string[] }>;
50+
51+
/** The public projection: identities dropped, their count kept. */
52+
export function withoutScanIdentities(dump: ScannedNetworkDump): NetworkDump {
53+
const { unnamedRequestIds: _identities, ...rest } = dump;
54+
return Object.freeze(rest);
55+
}
56+
4457
export function mergeNetworkDumps(
45-
primary: NetworkDump,
46-
secondary: NetworkDump,
58+
primary: ScannedNetworkDump,
59+
secondary: ScannedNetworkDump,
4760
maxEntries = primary.limits.maxEntries,
48-
): NetworkDump {
61+
): ScannedNetworkDump {
4962
const entries = [...primary.entries];
5063
const seen = new Set(entries.map(networkEntryKey));
5164
for (const entry of secondary.entries) {
@@ -70,14 +83,15 @@ export function mergeNetworkDumps(
7083
...primary,
7184
matchedLines: entries.length,
7285
entries: Object.freeze(entries),
86+
unnamedRequests: unnamedRequestIds.length,
7387
unnamedRequestIds: Object.freeze(unnamedRequestIds),
7488
});
7589
}
7690

7791
export function readRecentNetworkTrafficFromText(
7892
content: string,
7993
options: NetworkDumpParserOptions,
80-
): NetworkDump {
94+
): ScannedNetworkDump {
8195
const maxEntries = clampInt(options.maxEntries, 25, 1, 200);
8296
const include = options.include ?? 'summary';
8397
const maxPayloadChars = clampInt(options.maxPayloadChars, 2048, 64, 16_384);
@@ -90,7 +104,7 @@ export function readRecentNetworkTrafficFromText(
90104
scannedLines: 0,
91105
matchedLines: 0,
92106
entries: Object.freeze([]),
93-
unnamedRequestIds: Object.freeze([]),
107+
unnamedRequests: 0,
94108
include,
95109
limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }),
96110
});
@@ -102,6 +116,9 @@ export function readRecentNetworkTrafficFromText(
102116
const cfNetworkConnections = isAppleBackend(options.backend)
103117
? indexCfNetworkConnections(lines)
104118
: undefined;
119+
const unnamedRequestIds = cfNetworkConnections
120+
? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections)
121+
: [];
105122
for (let i = lines.length - 1; i >= 0 && entries.length < maxEntries; i -= 1) {
106123
if (!lines[i]?.trim()) continue;
107124
const parsed = parseNetworkLine(
@@ -121,9 +138,8 @@ export function readRecentNetworkTrafficFromText(
121138
scannedLines: lines.length,
122139
matchedLines: entries.length,
123140
entries: Object.freeze(entries),
124-
unnamedRequestIds: Object.freeze(
125-
cfNetworkConnections ? collectUnnamedCfNetworkTasks(lines, cfNetworkConnections) : [],
126-
),
141+
unnamedRequests: unnamedRequestIds.length,
142+
unnamedRequestIds: Object.freeze(unnamedRequestIds),
127143
include,
128144
limits: Object.freeze({ maxEntries, maxPayloadChars, maxScanLines }),
129145
});

packages/contracts/src/network-traffic.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@ export type NetworkDump = Readonly<{
2121
matchedLines: number;
2222
entries: readonly NetworkEntry[];
2323
/**
24-
* Identities of requests the reader observed but could not name at all, so
25-
* they are absent from `entries`: an empty dump with a non-empty list is a
26-
* failed capture, not evidence that nothing was requested. Identities rather
27-
* than a count, so two scan windows over overlapping traffic reconcile to the
28-
* requests actually seen instead of double-counting or under-reporting them.
24+
* How many requests the reader observed but could not name at all, so they
25+
* are absent from `entries`: an empty dump with a non-zero count is a failed
26+
* capture, not evidence that nothing was requested. A count rather than the
27+
* identities behind it, so the response stays bounded however many lines the
28+
* scan window held.
2929
*/
30-
unnamedRequestIds?: readonly string[];
30+
unnamedRequests?: number;
3131
include: NonNullable<NetworkDumpParserOptions['include']>;
3232
limits: Readonly<{ maxEntries: number; maxPayloadChars: number; maxScanLines: number }>;
3333
}>;

packages/platform-apple/src/network/runtime.test.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ test('a keep-alive request whose connection predates the window keeps the dump f
250250

251251
if (result.source !== 'app-log') throw new Error('expected app-log result');
252252
expect(result.dump.entries).toEqual([]);
253-
expect(result.dump.unnamedRequestIds).toHaveLength(1);
253+
expect(result.dump.unnamedRequests).toBe(1);
254254
expect(result.notes).toEqual([
255255
expect.stringContaining('1 opened before this scan window'),
256256
expect.stringContaining('No HTTP(s) entries were found'),
@@ -273,7 +273,7 @@ test('simulator recovery keeps traffic it saw but could not name', async () => {
273273

274274
if (result.source !== 'app-log') throw new Error('expected app-log result');
275275
expect(result.dump.entries).toEqual([]);
276-
expect(result.dump.unnamedRequestIds).toHaveLength(1);
276+
expect(result.dump.unnamedRequests).toBe(1);
277277
expect(result.notes).toEqual([
278278
expect.stringContaining('1 opened before this scan window'),
279279
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
295295
);
296296

297297
if (result.source !== 'app-log') throw new Error('expected app-log result');
298-
expect(result.dump.unnamedRequestIds).toHaveLength(1);
298+
expect(result.dump.unnamedRequests).toBe(1);
299299
expect(result.notes).toEqual([
300300
expect.stringContaining('1 opened before this scan window'),
301301
expect.stringContaining('No HTTP(s) entries were found'),
302302
]);
303303
expect(result.notes.join(' ')).not.toContain('none looked like HTTP traffic');
304304
});
305+
306+
test('a response bounded to one entry still reports every unnamed request, without their ids', async () => {
307+
// Five reused tasks, none resolvable: far more than the requested entry limit.
308+
const summaries = Array.from({ length: 5 }, (_, index) =>
309+
REUSED_SUMMARY.replace('Task <2FAEF670>.<2>', `Task <2FAEF670>.<${index + 10}>`),
310+
);
311+
const result = await dumpAppleNetworkTraffic(
312+
host({
313+
text: `${summaries.join('\n')}\n`,
314+
runSimctl: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 1 })),
315+
}),
316+
simulator,
317+
input({ maxEntries: 1, appLogSnapshot: { state: 'active', startedAt: 1_000 } }),
318+
new AbortController().signal,
319+
);
320+
321+
if (result.source !== 'app-log') throw new Error('expected app-log result');
322+
expect(result.dump.entries).toEqual([]);
323+
expect(result.dump.unnamedRequests).toBe(5);
324+
// The identities are a reconciliation detail and must not reach the response,
325+
// where their number is bounded by the scan window rather than by maxEntries.
326+
expect(result.dump).not.toHaveProperty('unnamedRequestIds');
327+
expect(result.notes[0]).toContain('5 requests reused a keep-alive connection');
328+
});

packages/platform-apple/src/network/runtime.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import type { NetworkDump } from '@agent-device/contracts/network-traffic';
22
import type { NetworkDumpInput, NetworkDumpResult } from '@agent-device/contracts/network-runtime';
33
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
4-
import { mergeNetworkDumps, readRecentNetworkTrafficFromText } from '@agent-device/capture-kit';
4+
import {
5+
mergeNetworkDumps,
6+
readRecentNetworkTrafficFromText,
7+
withoutScanIdentities,
8+
type ScannedNetworkDump,
9+
} from '@agent-device/capture-kit';
510
import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device';
611
import { backendForAppleDevice } from '../logs/backend.ts';
712

@@ -28,7 +33,12 @@ export async function dumpAppleNetworkTraffic(
2833
appendLifecycleNote(notes, device, input);
2934
appendUnnamedRequestNote(notes, dump);
3035
if (dump.entries.length === 0) notes.push(noEntriesNote(device));
31-
return Object.freeze({ source: 'app-log', backend, dump, notes: Object.freeze(notes) });
36+
return Object.freeze({
37+
source: 'app-log',
38+
backend,
39+
dump: withoutScanIdentities(dump),
40+
notes: Object.freeze(notes),
41+
});
3242
}
3343

3444
/**
@@ -38,12 +48,12 @@ export async function dumpAppleNetworkTraffic(
3848
*/
3949
function mergeRecoveredTraffic(
4050
notes: string[],
41-
dump: NetworkDump,
42-
recovery: { dump: NetworkDump; lineCount: number },
51+
dump: ScannedNetworkDump,
52+
recovery: { dump: ScannedNetworkDump; lineCount: number },
4353
maxEntries: number,
44-
): NetworkDump {
54+
): ScannedNetworkDump {
4555
const recovered = recovery.dump.entries.length;
46-
if (recovered === 0 && (recovery.dump.unnamedRequestIds ?? []).length === 0) {
56+
if (recovered === 0 && (recovery.dump.unnamedRequests ?? 0) === 0) {
4757
if (recovery.lineCount > 0) {
4858
notes.push(
4959
`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(
7888
input: NetworkDumpInput,
7989
appLogPath: string,
8090
signal: AbortSignal,
81-
): Promise<{ dump: NetworkDump; lineCount: number } | undefined> {
91+
): Promise<{ dump: ScannedNetworkDump; lineCount: number } | undefined> {
8292
const args = [
8393
...(device.simulatorSetPath ? ['--set', device.simulatorSetPath] : []),
8494
'spawn',
@@ -139,7 +149,7 @@ function buildPredicate(appBundleId: string): string {
139149
*/
140150
function appendUnnamedRequestNote(notes: string[], dump: NetworkDump): void {
141151
const againstOrigin = dump.entries.filter((entry) => entry.pathUnavailable).length;
142-
const unresolved = (dump.unnamedRequestIds ?? []).length;
152+
const unresolved = dump.unnamedRequests ?? 0;
143153
const observed = againstOrigin + unresolved;
144154
if (observed === 0) return;
145155
const parts = [

website/docs/docs/commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ agent-device network dump 25 --include headers --platform web # Browser requests
991991
- iOS simulator log capture now streams from inside the simulator with `simctl spawn <udid> log ...`, and `network dump` can recover recent simulator log history with `simctl log show` when the live app-log window is sparse.
992992
- iOS log capture still relies on Unified Logging signals (for example `os_log`); plain stdout/stderr output may be limited depending on app/runtime.
993993
- 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.
994-
- 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.
994+
- 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.
995995
- Retention knobs: set `AGENT_DEVICE_APP_LOG_MAX_BYTES` and `AGENT_DEVICE_APP_LOG_MAX_FILES` to override rotation limits.
996996
- Optional write-time redaction patterns: set `AGENT_DEVICE_APP_LOG_REDACT_PATTERNS` to a comma-separated regex list.
997997

0 commit comments

Comments
 (0)