Skip to content

Commit 2d7aca8

Browse files
authored
Merge pull request #2593 from GoogleChrome/streamingjson
Add streaming JSON parser and file writer
2 parents fadaef9 + 5363ba3 commit 2d7aca8

File tree

6 files changed

+339
-27
lines changed

6 files changed

+339
-27
lines changed

lighthouse-core/gather/driver.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const emulation = require('../lib/emulation');
1010
const Element = require('../lib/element');
1111
const EventEmitter = require('events').EventEmitter;
1212
const URL = require('../lib/url-shim');
13+
const TraceParser = require('../lib/traces/trace-parser');
1314

1415
const log = require('lighthouse-logger');
1516
const DevtoolsLog = require('./devtools-log');
@@ -801,7 +802,7 @@ class Driver {
801802
_readTraceFromStream(streamHandle) {
802803
return new Promise((resolve, reject) => {
803804
let isEOF = false;
804-
let result = '';
805+
const parser = new TraceParser();
805806

806807
const readArguments = {
807808
handle: streamHandle.stream
@@ -812,11 +813,11 @@ class Driver {
812813
return;
813814
}
814815

815-
result += response.data;
816+
parser.parseChunk(response.data);
816817

817818
if (response.eof) {
818819
isEOF = true;
819-
return resolve(JSON.parse(result));
820+
return resolve(parser.getTrace());
820821
}
821822

822823
return this.sendCommand('IO.read', readArguments).then(onChunkRead);

lighthouse-core/lib/asset-saver.js

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
const fs = require('fs');
99
const log = require('lighthouse-logger');
10+
const stream = require('stream');
1011
const stringifySafe = require('json-stringify-safe');
1112
const Metrics = require('./traces/pwmetrics-events');
1213

@@ -102,6 +103,68 @@ function prepareAssets(artifacts, audits) {
102103
.then(_ => assets);
103104
}
104105

106+
/**
107+
* Generates a JSON representation of traceData line-by-line to avoid OOM due to
108+
* very large traces.
109+
* @param {{traceEvents: !Array}} traceData
110+
* @return {!Iterator<string>}
111+
*/
112+
function* traceJsonGenerator(traceData) {
113+
const keys = Object.keys(traceData);
114+
115+
yield '{\n';
116+
117+
// Stringify and emit trace events separately to avoid a giant string in memory.
118+
yield '"traceEvents": [\n';
119+
if (traceData.traceEvents.length > 0) {
120+
const eventsIterator = traceData.traceEvents[Symbol.iterator]();
121+
// Emit first item manually to avoid a trailing comma.
122+
const firstEvent = eventsIterator.next().value;
123+
yield ` ${JSON.stringify(firstEvent)}`;
124+
for (const event of eventsIterator) {
125+
yield `,\n ${JSON.stringify(event)}`;
126+
}
127+
}
128+
yield '\n]';
129+
130+
// Emit the rest of the object (usually just `metadata`)
131+
if (keys.length > 1) {
132+
for (const key of keys) {
133+
if (key === 'traceEvents') continue;
134+
135+
yield `,\n"${key}": ${JSON.stringify(traceData[key], null, 2)}`;
136+
}
137+
}
138+
139+
yield '}\n';
140+
}
141+
142+
/**
143+
* Save a trace as JSON by streaming to disk at traceFilename.
144+
* @param {{traceEvents: !Array}} traceData
145+
* @param {string} traceFilename
146+
* @return {!Promise<undefined>}
147+
*/
148+
function saveTrace(traceData, traceFilename) {
149+
return new Promise((resolve, reject) => {
150+
const traceIter = traceJsonGenerator(traceData);
151+
// A stream that pulls in the next traceJsonGenerator chunk as writeStream
152+
// reads from it. Closes stream with null when iteration is complete.
153+
const traceStream = new stream.Readable({
154+
read() {
155+
const next = traceIter.next();
156+
this.push(next.done ? null : next.value);
157+
}
158+
});
159+
160+
const writeStream = fs.createWriteStream(traceFilename);
161+
writeStream.on('finish', resolve);
162+
writeStream.on('error', reject);
163+
164+
traceStream.pipe(writeStream);
165+
});
166+
}
167+
105168
/**
106169
* Writes trace(s) and associated screenshot(s) to disk.
107170
* @param {!Artifacts} artifacts
@@ -111,28 +174,31 @@ function prepareAssets(artifacts, audits) {
111174
*/
112175
function saveAssets(artifacts, audits, pathWithBasename) {
113176
return prepareAssets(artifacts, audits).then(assets => {
114-
assets.forEach((data, index) => {
115-
const traceFilename = `${pathWithBasename}-${index}.trace.json`;
116-
fs.writeFileSync(traceFilename, JSON.stringify(data.traceData, null, 2));
117-
log.log('trace file saved to disk', traceFilename);
118-
177+
return Promise.all(assets.map((data, index) => {
119178
const devtoolsLogFilename = `${pathWithBasename}-${index}.devtoolslog.json`;
120179
fs.writeFileSync(devtoolsLogFilename, JSON.stringify(data.devtoolsLog, null, 2));
121-
log.log('devtools log saved to disk', devtoolsLogFilename);
180+
log.log('saveAssets', 'devtools log saved to disk: ' + devtoolsLogFilename);
122181

123182
const screenshotsHTMLFilename = `${pathWithBasename}-${index}.screenshots.html`;
124183
fs.writeFileSync(screenshotsHTMLFilename, data.screenshotsHTML);
125-
log.log('screenshots saved to disk', screenshotsHTMLFilename);
184+
log.log('saveAssets', 'screenshots saved to disk: ' + screenshotsHTMLFilename);
126185

127186
const screenshotsJSONFilename = `${pathWithBasename}-${index}.screenshots.json`;
128187
fs.writeFileSync(screenshotsJSONFilename, JSON.stringify(data.screenshots, null, 2));
129-
log.log('screenshots saved to disk', screenshotsJSONFilename);
130-
});
188+
log.log('saveAssets', 'screenshots saved to disk: ' + screenshotsJSONFilename);
189+
190+
const streamTraceFilename = `${pathWithBasename}-${index}.trace.json`;
191+
log.log('saveAssets', 'streaming trace file to disk: ' + streamTraceFilename);
192+
return saveTrace(data.traceData, streamTraceFilename).then(_ => {
193+
log.log('saveAssets', 'trace file streamed to disk: ' + streamTraceFilename);
194+
});
195+
}));
131196
});
132197
}
133198

134199
module.exports = {
135200
saveArtifacts,
136201
saveAssets,
137-
prepareAssets
202+
prepareAssets,
203+
saveTrace
138204
};
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license Copyright 2017 Google Inc. All Rights Reserved.
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
4+
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
5+
*/
6+
'use strict';
7+
8+
const WebInspector = require('../web-inspector');
9+
10+
/**
11+
* Traces > 256MB hit limits in V8, so TraceParser will parse the trace events stream as it's
12+
* received. We use DevTools' TimelineLoader for the heavy lifting, as it has a fast trace-specific
13+
* streaming JSON parser.
14+
* The resulting trace doesn't include the "metadata" property, as it's excluded via DevTools'
15+
* implementation.
16+
*/
17+
class TraceParser {
18+
constructor() {
19+
this.traceEvents = [];
20+
21+
this.tracingModel = {
22+
reset: _ => this._reset(),
23+
addEvents: evts => this._addEvents(evts),
24+
};
25+
26+
const delegateMock = {
27+
loadingProgress: _ => {},
28+
loadingStarted: _ => {},
29+
loadingComplete: success => {
30+
if (!success) throw new Error('Parsing problem');
31+
}
32+
};
33+
this.loader = new WebInspector.TimelineLoader(this.tracingModel, delegateMock);
34+
}
35+
36+
/**
37+
* Reset the trace events array
38+
*/
39+
_reset() {
40+
this.traceEvents = [];
41+
}
42+
43+
/**
44+
* Adds parsed trace events to array
45+
* @param {!Array<!TraceEvent>} evts
46+
*/
47+
_addEvents(evts) {
48+
this.traceEvents.push(...evts);
49+
}
50+
51+
/**
52+
* Receive chunk of streamed trace
53+
* @param {string} data
54+
*/
55+
parseChunk(data) {
56+
this.loader.write(data);
57+
}
58+
59+
/**
60+
* Returns entire trace
61+
* @return {{traceEvents: !Array<!TraceEvent>}}
62+
*/
63+
getTrace() {
64+
return {
65+
traceEvents: this.traceEvents
66+
};
67+
}
68+
}
69+
70+
module.exports = TraceParser;

lighthouse-core/lib/web-inspector.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ module.exports = (function() {
176176
require('chrome-devtools-frontend/front_end/timeline_model/TimelineModel.js');
177177
require('chrome-devtools-frontend/front_end/ui_lazy/SortableDataGrid.js');
178178
require('chrome-devtools-frontend/front_end/timeline/TimelineTreeView.js');
179+
180+
// used for streaming json parsing
181+
require('chrome-devtools-frontend/front_end/common/TextUtils.js');
182+
require('chrome-devtools-frontend/front_end/timeline/TimelineLoader.js');
183+
179184
require('chrome-devtools-frontend/front_end/timeline_model/TimelineProfileTree.js');
180185
require('chrome-devtools-frontend/front_end/components_lazy/FilmStripModel.js');
181186
require('chrome-devtools-frontend/front_end/timeline_model/TimelineIRModel.js');

lighthouse-core/test/lib/asset-saver-test.js

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const traceEvents = require('../fixtures/traces/progressive-app.json');
1515
const dbwTrace = require('../fixtures/traces/dbw_tester.json');
1616
const dbwResults = require('../fixtures/dbw_tester-perf-results.json');
1717
const Audit = require('../../audits/audit.js');
18+
const fullTraceObj = require('../fixtures/traces/progressive-app-m60.json');
1819

1920
/* eslint-env mocha */
2021
describe('asset-saver helper', () => {
@@ -34,24 +35,26 @@ describe('asset-saver helper', () => {
3435
});
3536

3637
describe('saves files', function() {
37-
const artifacts = {
38-
devtoolsLogs: {
39-
[Audit.DEFAULT_PASS]: [{message: 'first'}, {message: 'second'}]
40-
},
41-
traces: {
42-
[Audit.DEFAULT_PASS]: {
43-
traceEvents
44-
}
45-
},
46-
requestScreenshots: () => Promise.resolve(screenshotFilmstrip)
47-
};
38+
before(() => {
39+
const artifacts = {
40+
devtoolsLogs: {
41+
[Audit.DEFAULT_PASS]: [{message: 'first'}, {message: 'second'}]
42+
},
43+
traces: {
44+
[Audit.DEFAULT_PASS]: {
45+
traceEvents
46+
}
47+
},
48+
requestScreenshots: () => Promise.resolve(screenshotFilmstrip)
49+
};
4850

49-
assetSaver.saveAssets(artifacts, dbwResults.audits, process.cwd() + '/the_file');
51+
return assetSaver.saveAssets(artifacts, dbwResults.audits, process.cwd() + '/the_file');
52+
});
5053

51-
it('trace file saved to disk with data', () => {
54+
it('trace file saved to disk with only trace events', () => {
5255
const traceFilename = 'the_file-0.trace.json';
5356
const traceFileContents = fs.readFileSync(traceFilename, 'utf8');
54-
assert.ok(traceFileContents.length > 3000000);
57+
assert.deepStrictEqual(JSON.parse(traceFileContents), {traceEvents});
5558
fs.unlinkSync(traceFilename);
5659
});
5760

@@ -97,4 +100,81 @@ describe('asset-saver helper', () => {
97100
});
98101
});
99102
});
103+
104+
describe('saveTrace', () => {
105+
const traceFilename = 'test-trace-0.json';
106+
107+
afterEach(() => {
108+
fs.unlinkSync(traceFilename);
109+
});
110+
111+
it('correctly saves a trace with metadata to disk', () => {
112+
return assetSaver.saveTrace(fullTraceObj, traceFilename)
113+
.then(_ => {
114+
const traceFileContents = fs.readFileSync(traceFilename, 'utf8');
115+
assert.deepStrictEqual(JSON.parse(traceFileContents), fullTraceObj);
116+
});
117+
});
118+
119+
it('correctly saves a trace with no trace events to disk', () => {
120+
const trace = {
121+
traceEvents: [],
122+
metadata: {
123+
'clock-domain': 'MAC_MACH_ABSOLUTE_TIME',
124+
'cpu-family': 6,
125+
'cpu-model': 70,
126+
'cpu-stepping': 1,
127+
'field-trials': [],
128+
}
129+
};
130+
131+
return assetSaver.saveTrace(trace, traceFilename)
132+
.then(_ => {
133+
const traceFileContents = fs.readFileSync(traceFilename, 'utf8');
134+
assert.deepStrictEqual(JSON.parse(traceFileContents), trace);
135+
});
136+
});
137+
138+
it('correctly saves a trace with multiple extra properties to disk', () => {
139+
const trace = {
140+
traceEvents,
141+
metadata: fullTraceObj.metadata,
142+
someProp: 555,
143+
anotherProp: {
144+
unlikely: {
145+
nested: [
146+
'value'
147+
]
148+
}
149+
},
150+
};
151+
152+
return assetSaver.saveTrace(trace, traceFilename)
153+
.then(_ => {
154+
const traceFileContents = fs.readFileSync(traceFilename, 'utf8');
155+
assert.deepStrictEqual(JSON.parse(traceFileContents), trace);
156+
});
157+
});
158+
159+
it('can save traces over 256MB (slow)', () => {
160+
// Create a trace that wil be longer than 256MB when stringified, the hard
161+
// limit of a string in v8.
162+
// https://mobile.twitter.com/bmeurer/status/879276976523157505
163+
const baseEventsLength = JSON.stringify(traceEvents).length;
164+
const countNeeded = Math.ceil(Math.pow(2, 28) / baseEventsLength);
165+
let longTraceEvents = [];
166+
for (let i = 0; i < countNeeded; i++) {
167+
longTraceEvents = longTraceEvents.concat(traceEvents);
168+
}
169+
const trace = {
170+
traceEvents: longTraceEvents
171+
};
172+
173+
return assetSaver.saveTrace(trace, traceFilename)
174+
.then(_ => {
175+
const fileStats = fs.lstatSync(traceFilename);
176+
assert.ok(fileStats.size > Math.pow(2, 28));
177+
});
178+
});
179+
});
100180
});

0 commit comments

Comments
 (0)