Skip to content

Commit cc962f6

Browse files
committed
fix: include console messages logged before the server attached
1 parent 77e1d3f commit cc962f6

8 files changed

Lines changed: 624 additions & 22 deletions

File tree

src/McpPage.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
import {
6363
ConsoleCollector,
6464
NetworkCollector,
65+
type BufferedConsoleMessage,
6566
type ListenerMap,
6667
type UncaughtError,
6768
} from './PageCollector.js';
@@ -361,13 +362,24 @@ export class McpPage implements ContextPage {
361362

362363
getConsoleData(
363364
includePreservedMessages?: boolean,
364-
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
365+
): Array<
366+
| ConsoleMessage
367+
| BufferedConsoleMessage
368+
| Error
369+
| DevTools.AggregatedIssue
370+
| UncaughtError
371+
> {
365372
return this.consoleCollector.getData(includePreservedMessages);
366373
}
367374

368375
getConsoleMessageById(
369376
id: number,
370-
): ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError {
377+
):
378+
| ConsoleMessage
379+
| BufferedConsoleMessage
380+
| Error
381+
| DevTools.AggregatedIssue
382+
| UncaughtError {
371383
return this.consoleCollector.getById(id);
372384
}
373385

src/McpResponse.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import type {
2424
} from './HeapSnapshotManager.js';
2525
import type {McpContext} from './McpContext.js';
2626
import type {McpPage} from './McpPage.js';
27-
import {UncaughtError} from './PageCollector.js';
27+
import {BufferedConsoleMessage, UncaughtError} from './PageCollector.js';
2828
import {TextSnapshot} from './TextSnapshot.js';
2929
import {DevTools, getToonEncode, getGcfEncode} from './third_party/index.js';
3030
import type {
@@ -510,10 +510,13 @@ export class McpResponse implements Response {
510510
this.#attachedConsoleMessageId,
511511
);
512512
const consoleMessageStableId = this.#attachedConsoleMessageId;
513-
if ('args' in message || message instanceof UncaughtError) {
514-
const consoleMessage = message as ConsoleMessage | UncaughtError;
513+
if (
514+
message instanceof BufferedConsoleMessage ||
515+
message instanceof UncaughtError ||
516+
'args' in message
517+
) {
515518
const devTools = this.#page.devtoolsUniverse;
516-
detailedConsoleMessage = await ConsoleFormatter.from(consoleMessage, {
519+
detailedConsoleMessage = await ConsoleFormatter.from(message, {
517520
id: consoleMessageStableId,
518521
fetchDetailedData: true,
519522
devTools: devTools ?? undefined,
@@ -599,9 +602,12 @@ export class McpResponse implements Response {
599602
async (item): Promise<ConsoleFormatter | IssueFormatter | null> => {
600603
const consoleMessageStableId =
601604
this.getConsoleMessageStableId(item);
602-
if ('args' in item || item instanceof UncaughtError) {
603-
const consoleMessage = item as ConsoleMessage | UncaughtError;
604-
return await ConsoleFormatter.from(consoleMessage, {
605+
if (
606+
item instanceof BufferedConsoleMessage ||
607+
item instanceof UncaughtError ||
608+
'args' in item
609+
) {
610+
return await ConsoleFormatter.from(item, {
605611
id: consoleMessageStableId,
606612
fetchDetailedData: false,
607613
devTools: page ? page.devtoolsUniverse : undefined,
@@ -683,7 +689,12 @@ export class McpResponse implements Response {
683689
}
684690

685691
getConsoleMessageStableId(
686-
message: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError,
692+
message:
693+
| ConsoleMessage
694+
| BufferedConsoleMessage
695+
| Error
696+
| DevTools.AggregatedIssue
697+
| UncaughtError,
687698
): number {
688699
return (message as WithSymbolId<typeof message>)[stableIdSymbol] ?? -1;
689700
}

src/PageCollector.ts

Lines changed: 205 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {FakeIssuesManager} from './devtools/DevtoolsUtils.js';
88
import type {
99
CDPSession,
1010
ConsoleMessage,
11+
ConsoleMessageType,
1112
Protocol,
1213
Issue,
1314
} from './third_party/index.js';
@@ -36,6 +37,42 @@ export class UncaughtError {
3637
}
3738
}
3839

40+
/**
41+
* A console message recovered from Chromium's per-page buffer after the fact.
42+
* Replayed args cannot be adopted as live JSHandles, so it only carries their
43+
* text representation plus the raw stack trace for symbolization.
44+
*/
45+
export class BufferedConsoleMessage {
46+
readonly argsCount: number;
47+
readonly rawStackTrace?: Protocol.Runtime.StackTrace;
48+
readonly targetId: string;
49+
#type: ConsoleMessageType;
50+
#text: string;
51+
52+
constructor(params: {
53+
type: ConsoleMessageType;
54+
text: string;
55+
argsCount: number;
56+
rawStackTrace?: Protocol.Runtime.StackTrace;
57+
targetId: string;
58+
}) {
59+
this.#type = params.type;
60+
this.#text = params.text;
61+
this.argsCount = params.argsCount;
62+
this.rawStackTrace = params.rawStackTrace;
63+
this.targetId = params.targetId;
64+
}
65+
66+
// Accessors mirror ConsoleMessage so shared code paths work on both.
67+
type(): ConsoleMessageType {
68+
return this.#type;
69+
}
70+
71+
text(): string {
72+
return this.#text;
73+
}
74+
}
75+
3976
interface PageEvents extends PuppeteerPageEvents {
4077
devtoolsAggregatedIssue: DevTools.AggregatedIssue;
4178
uncaughtError: UncaughtError;
@@ -48,6 +85,7 @@ export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
4885
export class PageCollector<T> {
4986
protected pptrPage: Page;
5087
#listeners?: ListenerMap<PageEvents>;
88+
#idGenerator = createIdGenerator();
5189
protected maxNavigationSaved = 3;
5290

5391
/**
@@ -63,12 +101,8 @@ export class PageCollector<T> {
63101
) {
64102
this.pptrPage = page;
65103

66-
const idGenerator = createIdGenerator();
67-
68104
const listenerMap = listeners(value => {
69-
const withId = value as WithSymbolId<T>;
70-
withId[stableIdSymbol] = idGenerator();
71-
this.storage[0].push(withId);
105+
this.storage[0].push(this.#withStableId(value));
72106
});
73107

74108
listenerMap['framenavigated'] = (frame: Frame) => {
@@ -94,6 +128,22 @@ export class PageCollector<T> {
94128
}
95129
}
96130

131+
#withStableId(value: T): WithSymbolId<T> {
132+
const withId = value as WithSymbolId<T>;
133+
withId[stableIdSymbol] = this.#idGenerator();
134+
return withId;
135+
}
136+
137+
/**
138+
* Prepends items that were collected out-of-band and predate live
139+
* collection. The bucket is the navigation bucket the items belong to,
140+
* which may no longer be the current one if the page navigated in the
141+
* meantime.
142+
*/
143+
protected prepend(items: T[], bucket: Array<WithSymbolId<T>>): void {
144+
bucket.unshift(...items.map(item => this.#withStableId(item)));
145+
}
146+
97147
protected splitAfterNavigation() {
98148
// Add the latest navigation first
99149
this.storage.unshift([]);
@@ -142,21 +192,110 @@ export class PageCollector<T> {
142192
}
143193

144194
export class ConsoleCollector extends PageCollector<
145-
ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError
195+
| ConsoleMessage
196+
| BufferedConsoleMessage
197+
| Error
198+
| DevTools.AggregatedIssue
199+
| UncaughtError
146200
> {
147201
#subscriber?: PageEventSubscriber;
202+
#backfill: Promise<void>;
148203

149204
constructor(
150205
page: Page,
151206
listeners: (
152207
collector: (
153-
item: ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError,
208+
item:
209+
| ConsoleMessage
210+
| BufferedConsoleMessage
211+
| Error
212+
| DevTools.AggregatedIssue
213+
| UncaughtError,
154214
) => void,
155215
) => ListenerMap<PageEvents>,
156216
) {
157217
super(page, listeners);
158218
this.#subscriber = new PageEventSubscriber(this.pptrPage);
159219
this.#subscriber.subscribe();
220+
this.#backfill = this.#backfillBufferedMessages();
221+
}
222+
223+
/**
224+
* Resolves once messages buffered before this collector attached have been
225+
* recovered.
226+
*/
227+
get backfillForTesting(): Promise<void> {
228+
return this.#backfill;
229+
}
230+
231+
/**
232+
* Chromium buffers console messages, uncaught exceptions, and log entries
233+
* per page and replays them to every new CDP session that enables the
234+
* respective domain. Puppeteer consumes that replay internally while
235+
* connecting, before any `console` listener can subscribe, so anything
236+
* logged before this collector attached would otherwise be missing.
237+
* Recover it through a throwaway CDP session. Events stamped after the
238+
* attach time are dropped because the live listeners already collect them.
239+
*/
240+
async #backfillBufferedMessages(): Promise<void> {
241+
// Called from the constructor and runs synchronously up to the first
242+
// await, so both capture the attach-time state.
243+
const attachedAt = Date.now();
244+
const bucket = this.storage[0];
245+
try {
246+
const session = await this.pptrPage.createCDPSession();
247+
try {
248+
const {targetInfo} = await session.send('Target.getTargetInfo');
249+
const targetId = targetInfo.targetId;
250+
const buffered: Array<{
251+
timestamp: number;
252+
item: BufferedConsoleMessage | UncaughtError;
253+
}> = [];
254+
session.on('Runtime.consoleAPICalled', event => {
255+
if (event.timestamp <= attachedAt) {
256+
buffered.push({
257+
timestamp: event.timestamp,
258+
item: createBufferedConsoleMessage(event, targetId),
259+
});
260+
}
261+
});
262+
session.on('Runtime.exceptionThrown', event => {
263+
if (event.timestamp <= attachedAt) {
264+
buffered.push({
265+
timestamp: event.timestamp,
266+
item: new UncaughtError(event.exceptionDetails, targetId),
267+
});
268+
}
269+
});
270+
session.on('Log.entryAdded', event => {
271+
const entry = event.entry;
272+
// Puppeteer skips worker log entries in its live conversion.
273+
if (entry.timestamp <= attachedAt && entry.source !== 'worker') {
274+
buffered.push({
275+
timestamp: entry.timestamp,
276+
item: createBufferedLogMessage(entry, targetId),
277+
});
278+
}
279+
});
280+
// Buffered events are replayed before the respective call resolves.
281+
await session.send('Runtime.enable');
282+
await session.send('Log.enable');
283+
if (buffered.length > 0) {
284+
// Merge the replays of both domains chronologically.
285+
this.prepend(
286+
buffered
287+
.sort((a, b) => a.timestamp - b.timestamp)
288+
.map(entry => entry.item),
289+
bucket,
290+
);
291+
}
292+
} finally {
293+
// Detaching also releases the remote objects held by replayed args.
294+
await session.detach();
295+
}
296+
} catch (error) {
297+
logger?.('Error backfilling buffered console messages', error);
298+
}
160299
}
161300

162301
override dispose(): void {
@@ -165,6 +304,65 @@ export class ConsoleCollector extends PageCollector<
165304
}
166305
}
167306

307+
/**
308+
* Creates a BufferedConsoleMessage from a replayed `Runtime.consoleAPICalled`
309+
* event, converting the type and args text the same way Puppeteer converts
310+
* live events.
311+
*/
312+
function createBufferedConsoleMessage(
313+
event: Protocol.Runtime.ConsoleAPICalledEvent,
314+
targetId: string,
315+
): BufferedConsoleMessage {
316+
return new BufferedConsoleMessage({
317+
type: event.type === 'warning' ? 'warn' : event.type,
318+
text: event.args
319+
.map(remoteObject => textFromRemoteObject(remoteObject))
320+
.join(' '),
321+
argsCount: event.args.length,
322+
rawStackTrace: event.stackTrace,
323+
targetId,
324+
});
325+
}
326+
327+
/**
328+
* Creates a BufferedConsoleMessage from a replayed `Log.entryAdded` event,
329+
* mirroring Puppeteer's own conversion.
330+
*/
331+
function createBufferedLogMessage(
332+
entry: Protocol.Log.LogEntry,
333+
targetId: string,
334+
): BufferedConsoleMessage {
335+
return new BufferedConsoleMessage({
336+
type: entry.level === 'warning' ? 'warn' : entry.level,
337+
text: entry.text,
338+
argsCount: 0,
339+
rawStackTrace: entry.stackTrace,
340+
targetId,
341+
});
342+
}
343+
344+
/**
345+
* Mirrors Puppeteer's `valueFromJSHandle` for args that are only available
346+
* as protocol RemoteObjects.
347+
*/
348+
function textFromRemoteObject(
349+
remoteObject: Protocol.Runtime.RemoteObject,
350+
): string {
351+
if (remoteObject.objectId) {
352+
const description = remoteObject.description ?? '';
353+
if (remoteObject.subtype === 'error' && description) {
354+
return description.split('\n')[0];
355+
}
356+
return `[${remoteObject.subtype || remoteObject.type} ${remoteObject.className}]`;
357+
}
358+
if (remoteObject.unserializableValue) {
359+
return remoteObject.type === 'bigint'
360+
? remoteObject.unserializableValue.replace('n', '')
361+
: remoteObject.unserializableValue;
362+
}
363+
return String(remoteObject.value);
364+
}
365+
168366
class PageEventSubscriber {
169367
#issueManager = new FakeIssuesManager();
170368
#issueAggregator = new DevTools.IssueAggregator(this.#issueManager);

0 commit comments

Comments
 (0)