chore(tracing): rework start/export chunks (#8378)
Instead of filtering the whole trace file on export, we write into separate trace file for each chunk. We also write a separate trace.network file with all resources, because it is reused between chunks. This brings us towards `tracing.startFile()/stopFile()` api.
This commit is contained in:
parent
0ed3c79d51
commit
47724fed5a
|
|
@ -50,15 +50,9 @@ export type FrameSnapshotTraceEvent = {
|
||||||
snapshot: FrameSnapshot,
|
snapshot: FrameSnapshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MarkerTraceEvent = {
|
|
||||||
type: 'marker',
|
|
||||||
resetIndex?: number,
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TraceEvent =
|
export type TraceEvent =
|
||||||
ContextCreatedTraceEvent |
|
ContextCreatedTraceEvent |
|
||||||
ScreencastFrameTraceEvent |
|
ScreencastFrameTraceEvent |
|
||||||
ActionTraceEvent |
|
ActionTraceEvent |
|
||||||
ResourceSnapshotTraceEvent |
|
ResourceSnapshotTraceEvent |
|
||||||
FrameSnapshotTraceEvent |
|
FrameSnapshotTraceEvent;
|
||||||
MarkerTraceEvent;
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import yazl from 'yazl';
|
import yazl from 'yazl';
|
||||||
import readline from 'readline';
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
|
import { createGuid, mkdirIfNeeded, monotonicTime } from '../../../utils/utils';
|
||||||
import { Artifact } from '../../artifact';
|
import { Artifact } from '../../artifact';
|
||||||
|
|
@ -41,6 +40,8 @@ export const VERSION = 2;
|
||||||
|
|
||||||
type RecordingState = {
|
type RecordingState = {
|
||||||
options: TracerOptions,
|
options: TracerOptions,
|
||||||
|
traceName: string,
|
||||||
|
networkFile: string,
|
||||||
traceFile: string,
|
traceFile: string,
|
||||||
lastReset: number,
|
lastReset: number,
|
||||||
sha1s: Set<string>,
|
sha1s: Set<string>,
|
||||||
|
|
@ -59,12 +60,19 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
private _isStopping = false;
|
private _isStopping = false;
|
||||||
private _tracesDir: string;
|
private _tracesDir: string;
|
||||||
private _allResources = new Set<string>();
|
private _allResources = new Set<string>();
|
||||||
|
private _contextCreatedEvent: trace.ContextCreatedTraceEvent;
|
||||||
|
|
||||||
constructor(context: BrowserContext) {
|
constructor(context: BrowserContext) {
|
||||||
this._context = context;
|
this._context = context;
|
||||||
this._tracesDir = context._browser.options.tracesDir;
|
this._tracesDir = context._browser.options.tracesDir;
|
||||||
this._resourcesDir = path.join(this._tracesDir, 'resources');
|
this._resourcesDir = path.join(this._tracesDir, 'resources');
|
||||||
this._snapshotter = new Snapshotter(context, this);
|
this._snapshotter = new Snapshotter(context, this);
|
||||||
|
this._contextCreatedEvent = {
|
||||||
|
version: VERSION,
|
||||||
|
type: 'context-options',
|
||||||
|
browserName: this._context._browser.options.name,
|
||||||
|
options: this._context._options
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(options: TracerOptions): Promise<void> {
|
async start(options: TracerOptions): Promise<void> {
|
||||||
|
|
@ -76,16 +84,12 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
// TODO: passing the same name for two contexts makes them write into a single file
|
// TODO: passing the same name for two contexts makes them write into a single file
|
||||||
// and conflict.
|
// and conflict.
|
||||||
const traceFile = path.join(this._tracesDir, (options.name || createGuid()) + '.trace');
|
const traceName = options.name || createGuid();
|
||||||
this._recording = { options, traceFile, lastReset: 0, sha1s: new Set() };
|
const traceFile = path.join(this._tracesDir, traceName + '.trace');
|
||||||
this._writeChain = mkdirIfNeeded(traceFile);
|
const networkFile = path.join(this._tracesDir, traceName + '.network');
|
||||||
const event: trace.ContextCreatedTraceEvent = {
|
this._recording = { options, traceName, traceFile, networkFile, lastReset: 0, sha1s: new Set() };
|
||||||
version: VERSION,
|
this._writeChain = mkdirIfNeeded(traceFile).then(() => fs.promises.writeFile(networkFile, ''));
|
||||||
type: 'context-options',
|
this._appendTraceEvent(this._contextCreatedEvent);
|
||||||
browserName: this._context._browser.options.name,
|
|
||||||
options: this._context._options
|
|
||||||
};
|
|
||||||
this._appendTraceEvent(event);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state?.options?.screenshots && options.screenshots)
|
if (!state?.options?.screenshots && options.screenshots)
|
||||||
|
|
@ -111,8 +115,8 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
state.lastReset++;
|
state.lastReset++;
|
||||||
const markerEvent: trace.MarkerTraceEvent = { type: 'marker', resetIndex: state.lastReset };
|
state.traceFile = path.join(this._tracesDir, `${state.traceName}-${state.lastReset}.trace`);
|
||||||
await fs.promises.appendFile(state.traceFile, JSON.stringify(markerEvent) + '\n');
|
await fs.promises.appendFile(state.traceFile, JSON.stringify(this._contextCreatedEvent) + '\n');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,18 +174,14 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
throw new Error('Must start tracing before exporting');
|
throw new Error('Must start tracing before exporting');
|
||||||
|
|
||||||
// Chain the export operation against write operations,
|
// Chain the export operation against write operations,
|
||||||
// so that neither trace file nor sha1s change during the export.
|
// so that neither trace files nor sha1s change during the export.
|
||||||
return await this._appendTraceOperation(async () => {
|
return await this._appendTraceOperation(async () => {
|
||||||
const recording = this._recording!;
|
const state = this._recording!;
|
||||||
let state = recording;
|
|
||||||
// Make a filtered trace if needed.
|
|
||||||
if (recording.lastReset)
|
|
||||||
state = await this._filterTrace(recording, recording.lastReset);
|
|
||||||
|
|
||||||
const zipFile = new yazl.ZipFile();
|
const zipFile = new yazl.ZipFile();
|
||||||
const failedPromise = new Promise<Artifact>((_, reject) => (zipFile as any as EventEmitter).on('error', reject));
|
const failedPromise = new Promise<Artifact>((_, reject) => (zipFile as any as EventEmitter).on('error', reject));
|
||||||
const succeededPromise = new Promise<Artifact>(async fulfill => {
|
const succeededPromise = new Promise<Artifact>(async fulfill => {
|
||||||
zipFile.addFile(state.traceFile, 'trace.trace');
|
zipFile.addFile(state.traceFile, 'trace.trace');
|
||||||
|
zipFile.addFile(state.networkFile, 'trace.network');
|
||||||
const zipFileName = state.traceFile + '.zip';
|
const zipFileName = state.traceFile + '.zip';
|
||||||
for (const sha1 of state.sha1s)
|
for (const sha1 of state.sha1s)
|
||||||
zipFile.addFile(path.join(this._resourcesDir, sha1), path.join('resources', sha1));
|
zipFile.addFile(path.join(this._resourcesDir, sha1), path.join('resources', sha1));
|
||||||
|
|
@ -193,55 +193,10 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
artifact.reportFinished();
|
artifact.reportFinished();
|
||||||
fulfill(artifact);
|
fulfill(artifact);
|
||||||
});
|
});
|
||||||
return Promise.race([failedPromise, succeededPromise]).finally(async () => {
|
return Promise.race([failedPromise, succeededPromise]);
|
||||||
// Remove the filtered trace.
|
|
||||||
if (recording.lastReset)
|
|
||||||
await fs.promises.unlink(state.traceFile).catch(() => {});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _filterTrace(state: RecordingState, sinceResetIndex: number): Promise<RecordingState> {
|
|
||||||
const ext = path.extname(state.traceFile);
|
|
||||||
const traceFileCopy = state.traceFile.substring(0, state.traceFile.length - ext.length) + '-copy' + sinceResetIndex + ext;
|
|
||||||
const sha1s = new Set<string>();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const fileStream = fs.createReadStream(state.traceFile, 'utf8');
|
|
||||||
const rl = readline.createInterface({
|
|
||||||
input: fileStream,
|
|
||||||
crlfDelay: Infinity
|
|
||||||
});
|
|
||||||
let copyChain = Promise.resolve();
|
|
||||||
let foundMarker = false;
|
|
||||||
rl.on('line', line => {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(line) as trace.TraceEvent;
|
|
||||||
if (event.type === 'marker') {
|
|
||||||
if (event.resetIndex === sinceResetIndex)
|
|
||||||
foundMarker = true;
|
|
||||||
} else if ((event.type === 'resource-snapshot' && state.options.snapshots) || event.type === 'context-options' || foundMarker) {
|
|
||||||
// We keep:
|
|
||||||
// - old resource events for snapshots;
|
|
||||||
// - initial context options event;
|
|
||||||
// - all events after the marker that are not markers.
|
|
||||||
visitSha1s(event, sha1s);
|
|
||||||
copyChain = copyChain.then(() => fs.promises.appendFile(traceFileCopy, line + '\n'));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
fileStream.close();
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
rl.on('error', reject);
|
|
||||||
rl.on('close', async () => {
|
|
||||||
await copyChain;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return { options: state.options, lastReset: state.lastReset, sha1s, traceFile: traceFileCopy };
|
|
||||||
}
|
|
||||||
|
|
||||||
async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) {
|
async _captureSnapshot(name: 'before' | 'after' | 'action' | 'event', sdkObject: SdkObject, metadata: CallMetadata, element?: ElementHandle) {
|
||||||
if (!sdkObject.attribution.page)
|
if (!sdkObject.attribution.page)
|
||||||
return;
|
return;
|
||||||
|
|
@ -293,7 +248,11 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
onResourceSnapshot(snapshot: ResourceSnapshot): void {
|
onResourceSnapshot(snapshot: ResourceSnapshot): void {
|
||||||
this._appendTraceEvent({ type: 'resource-snapshot', snapshot });
|
const event: trace.ResourceSnapshotTraceEvent = { type: 'resource-snapshot', snapshot };
|
||||||
|
this._appendTraceOperation(async () => {
|
||||||
|
visitSha1s(event, this._recording!.sha1s);
|
||||||
|
await fs.promises.appendFile(this._recording!.networkFile, JSON.stringify(event) + '\n');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onFrameSnapshot(snapshot: FrameSnapshot): void {
|
onFrameSnapshot(snapshot: FrameSnapshot): void {
|
||||||
|
|
@ -324,7 +283,6 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _appendTraceEvent(event: trace.TraceEvent) {
|
private _appendTraceEvent(event: trace.TraceEvent) {
|
||||||
// Serialize all writes to the trace file.
|
|
||||||
this._appendTraceOperation(async () => {
|
this._appendTraceOperation(async () => {
|
||||||
visitSha1s(event, this._recording!.sha1s);
|
visitSha1s(event, this._recording!.sha1s);
|
||||||
await fs.promises.appendFile(this._recording!.traceFile, JSON.stringify(event) + '\n');
|
await fs.promises.appendFile(this._recording!.traceFile, JSON.stringify(event) + '\n');
|
||||||
|
|
@ -349,6 +307,7 @@ export class Tracing implements InstrumentationListener, SnapshotterDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _appendTraceOperation<T>(cb: () => Promise<T>): Promise<T> {
|
private async _appendTraceOperation<T>(cb: () => Promise<T>): Promise<T> {
|
||||||
|
// This method serializes all writes to the trace.
|
||||||
let error: Error | undefined;
|
let error: Error | undefined;
|
||||||
let result: T | undefined;
|
let result: T | undefined;
|
||||||
this._writeChain = this._writeChain.then(async () => {
|
this._writeChain = this._writeChain.then(async () => {
|
||||||
|
|
|
||||||
|
|
@ -74,19 +74,16 @@ export class TraceViewer {
|
||||||
|
|
||||||
const traceModelHandler: ServerRouteHandler = (request, response) => {
|
const traceModelHandler: ServerRouteHandler = (request, response) => {
|
||||||
const debugName = request.url!.substring('/context/'.length);
|
const debugName = request.url!.substring('/context/'.length);
|
||||||
const tracePrefix = path.join(tracesDir, debugName);
|
|
||||||
snapshotStorage.clear();
|
snapshotStorage.clear();
|
||||||
response.statusCode = 200;
|
response.statusCode = 200;
|
||||||
response.setHeader('Content-Type', 'application/json');
|
response.setHeader('Content-Type', 'application/json');
|
||||||
(async () => {
|
(async () => {
|
||||||
const fileStream = fs.createReadStream(tracePrefix + '.trace', 'utf8');
|
const traceFile = path.join(tracesDir, debugName + '.trace');
|
||||||
const rl = readline.createInterface({
|
const match = debugName.match(/^(.*)-\d+$/);
|
||||||
input: fileStream,
|
const networkFile = path.join(tracesDir, (match ? match[1] : debugName) + '.network');
|
||||||
crlfDelay: Infinity
|
|
||||||
});
|
|
||||||
const model = new TraceModel(snapshotStorage);
|
const model = new TraceModel(snapshotStorage);
|
||||||
for await (const line of rl as any)
|
await appendTraceEvents(model, traceFile);
|
||||||
model.appendEvent(line);
|
await appendTraceEvents(model, networkFile);
|
||||||
model.build();
|
model.build();
|
||||||
response.end(JSON.stringify(model.contextEntry));
|
response.end(JSON.stringify(model.contextEntry));
|
||||||
})().catch(e => console.error(e));
|
})().catch(e => console.error(e));
|
||||||
|
|
@ -187,6 +184,16 @@ Please run 'npx playwright install' to install Playwright browsers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function appendTraceEvents(model: TraceModel, file: string) {
|
||||||
|
const fileStream = fs.createReadStream(file, 'utf8');
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: fileStream,
|
||||||
|
crlfDelay: Infinity
|
||||||
|
});
|
||||||
|
for await (const line of rl as any)
|
||||||
|
model.appendEvent(line);
|
||||||
|
}
|
||||||
|
|
||||||
export async function showTraceViewer(tracePath: string, browserName: string, headless = false): Promise<BrowserContext | undefined> {
|
export async function showTraceViewer(tracePath: string, browserName: string, headless = false): Promise<BrowserContext | undefined> {
|
||||||
let stat;
|
let stat;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ test('should reset to different options', async ({ context, page, server }, test
|
||||||
expect(events.find(e => e.metadata?.apiName === 'page.click')).toBeTruthy();
|
expect(events.find(e => e.metadata?.apiName === 'page.click')).toBeTruthy();
|
||||||
|
|
||||||
expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy();
|
expect(events.some(e => e.type === 'frame-snapshot')).toBeFalsy();
|
||||||
expect(events.some(e => e.type === 'resource-snapshot')).toBeFalsy();
|
expect(events.some(e => e.type === 'resource-snapshot')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reset and export', async ({ context, page, server }, testInfo) => {
|
test('should reset and export', async ({ context, page, server }, testInfo) => {
|
||||||
|
|
@ -300,7 +300,15 @@ async function parseTrace(file: string): Promise<{ events: any[], resources: Map
|
||||||
const resources = new Map<string, Buffer>();
|
const resources = new Map<string, Buffer>();
|
||||||
for (const { name, buffer } of await Promise.all(entries))
|
for (const { name, buffer } of await Promise.all(entries))
|
||||||
resources.set(name, buffer);
|
resources.set(name, buffer);
|
||||||
const events = resources.get('trace.trace').toString().split('\n').map(line => line ? JSON.parse(line) : false).filter(Boolean);
|
const events = [];
|
||||||
|
for (const line of resources.get('trace.trace').toString().split('\n')) {
|
||||||
|
if (line)
|
||||||
|
events.push(JSON.parse(line));
|
||||||
|
}
|
||||||
|
for (const line of resources.get('trace.network').toString().split('\n')) {
|
||||||
|
if (line)
|
||||||
|
events.push(JSON.parse(line));
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
events,
|
events,
|
||||||
resources,
|
resources,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue