Symbolicate JS stacktrace using RN Packager

Summary:
The way we currently symbolicate JS stack traces in RN during development time
(e.g. inside the RedBox) is the following: we download the source map from RN,
parse it and use `source-map` find original file/line numbers. All happens
inside running JSC VM in a simulator.

The problem with this approach is that the source map size is pretty big and it
is very expensive to load/parse.

Before we load sourcemaps:
{F60869250}

After we load sourcemaps:
{F60869249}

In the past it wasn't a big problem, however the sourcemap file is only getting
larger and soon we will be loading it for yellow boxes too: https://github.com/facebook/react-native/pull/7459

Moving stack trace symbolication to server side will let us:
- save a bunch of memory on device
- improve performance (no need to JSON serialize/deserialize and transfer sourcemap via HTTP and bridge)
- remove ugly workaround with `RCTExceptionsManager.updateExceptionMessage`
- we will be able to symbolicate from native by simply sending HTTP request, which means symbolication
  can be more robust (no need to depend on crashed JS to do symbolication) and we can pause JSC to
  avoid getting too many redboxes that hide original error.
- reduce the bundle by ~65KB (the size of source-map parsing library we ship, see SourceMap module)

Reviewed By: davidaurelio

Differential Revision: D3291793

fbshipit-source-id: 29dce5f40100259264f57254e6715ace8ea70174
This commit is contained in:
Alex Kotliarskyi 2016-05-20 12:12:58 -07:00 committed by Facebook Github Bot 8
parent 17f30d8666
commit 31131746d8
2 changed files with 119 additions and 0 deletions

View File

@ -14,6 +14,7 @@ jest.setMock('worker-farm', function() { return () => {}; })
.setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) }) .setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) })
.setMock('uglify-js') .setMock('uglify-js')
.setMock('crypto') .setMock('crypto')
.setMock('source-map', { SourceMapConsumer: (fn) => {}})
.mock('../../Bundler') .mock('../../Bundler')
.mock('../../AssetServer') .mock('../../AssetServer')
.mock('../../lib/declareOpts') .mock('../../lib/declareOpts')
@ -21,6 +22,7 @@ jest.setMock('worker-farm', function() { return () => {}; })
.mock('../../Activity'); .mock('../../Activity');
const Promise = require('promise'); const Promise = require('promise');
const SourceMapConsumer = require('source-map').SourceMapConsumer;
const Bundler = require('../../Bundler'); const Bundler = require('../../Bundler');
const Server = require('../'); const Server = require('../');
@ -392,4 +394,59 @@ describe('processRequest', () => {
); );
}); });
}); });
describe('/symbolicate endpoint', () => {
pit('should symbolicate given stack trace', () => {
const body = JSON.stringify({stack: [{
file: 'foo.bundle?platform=ios',
lineNumber: 2100,
column: 44,
customPropShouldBeLeftUnchanged: 'foo',
}]});
SourceMapConsumer.prototype.originalPositionFor = jest.fn((frame) => {
expect(frame.line).toEqual(2100);
expect(frame.column).toEqual(44);
return {
source: 'foo.js',
line: 21,
column: 4,
};
});
return makeRequest(
requestHandler,
'/symbolicate',
{ rawBody: body }
).then(response => {
expect(JSON.parse(response.body)).toEqual({
stack: [{
file: 'foo.js',
lineNumber: 21,
column: 4,
customPropShouldBeLeftUnchanged: 'foo',
}]
});
});
});
});
describe('/symbolicate handles errors', () => {
pit('should symbolicate given stack trace', () => {
const body = 'clearly-not-json';
console.error = jest.fn();
return makeRequest(
requestHandler,
'/symbolicate',
{ rawBody: body }
).then(response => {
expect(response.statusCode).toEqual(500);
expect(JSON.parse(response.body)).toEqual({
error: jasmine.any(String),
});
expect(console.error).toBeCalled();
});
});
});
}); });

View File

@ -14,6 +14,7 @@ const FileWatcher = require('node-haste').FileWatcher;
const getPlatformExtension = require('node-haste').getPlatformExtension; const getPlatformExtension = require('node-haste').getPlatformExtension;
const Bundler = require('../Bundler'); const Bundler = require('../Bundler');
const Promise = require('promise'); const Promise = require('promise');
const SourceMapConsumer = require('source-map').SourceMapConsumer;
const _ = require('lodash'); const _ = require('lodash');
const declareOpts = require('../lib/declareOpts'); const declareOpts = require('../lib/declareOpts');
@ -428,6 +429,9 @@ class Server {
} else if (pathname.match(/^\/assets\//)) { } else if (pathname.match(/^\/assets\//)) {
this._processAssetsRequest(req, res); this._processAssetsRequest(req, res);
return; return;
} else if (pathname === '/symbolicate') {
this._symbolicate(req, res);
return;
} else { } else {
next(); next();
return; return;
@ -480,6 +484,64 @@ class Server {
).done(); ).done();
} }
_symbolicate(req, res) {
const startReqEventId = Activity.startEvent('symbolicate');
new Promise.resolve(req.rawBody).then(body => {
const stack = JSON.parse(body).stack;
// In case of multiple bundles / HMR, some stack frames can have
// different URLs from others
const urls = stack.map(frame => frame.file);
const uniqueUrls = urls.filter((elem, idx) => urls.indexOf(elem) === idx);
const sourceMaps = uniqueUrls.map(sourceUrl => this._sourceMapForURL(sourceUrl));
return Promise.all(sourceMaps).then(consumers => {
return stack.map(frame => {
const idx = uniqueUrls.indexOf(frame.file);
const consumer = consumers[idx];
const original = consumer.originalPositionFor({
line: frame.lineNumber,
column: frame.column,
});
if (!original) {
return frame;
}
return Object.assign({}, frame, {
file: original.source,
lineNumber: original.line,
column: original.column,
});
});
});
}).then(
stack => res.end(JSON.stringify({stack: stack})),
error => {
console.error(error.stack || error);
res.statusCode = 500;
res.end(JSON.stringify({error: error.message}));
}
).done(() => {
Activity.endEvent(startReqEventId);
});
}
_sourceMapForURL(reqUrl) {
const options = this._getOptionsFromUrl(reqUrl);
const optionsJson = JSON.stringify(options);
const building = this._bundles[optionsJson] || this.buildBundle(options);
this._bundles[optionsJson] = building;
return building.then(p => {
const sourceMap = p.getSourceMap({
minify: options.minify,
dev: options.dev,
});
return new SourceMapConsumer(sourceMap);
});
}
_handleError(res, bundleID, error) { _handleError(res, bundleID, error) {
res.writeHead(error.status || 500, { res.writeHead(error.status || 500, {
'Content-Type': 'application/json; charset=UTF-8', 'Content-Type': 'application/json; charset=UTF-8',