From 31131746d8f23722cc111032b8af4d01c8779e3e Mon Sep 17 00:00:00 2001 From: Alex Kotliarskyi Date: Fri, 20 May 2016 12:12:58 -0700 Subject: [PATCH] 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 --- .../src/Server/__tests__/Server-test.js | 57 +++++++++++++++++ react-packager/src/Server/index.js | 62 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/react-packager/src/Server/__tests__/Server-test.js b/react-packager/src/Server/__tests__/Server-test.js index c96b9ce1..9d5ec47a 100644 --- a/react-packager/src/Server/__tests__/Server-test.js +++ b/react-packager/src/Server/__tests__/Server-test.js @@ -14,6 +14,7 @@ jest.setMock('worker-farm', function() { return () => {}; }) .setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) }) .setMock('uglify-js') .setMock('crypto') + .setMock('source-map', { SourceMapConsumer: (fn) => {}}) .mock('../../Bundler') .mock('../../AssetServer') .mock('../../lib/declareOpts') @@ -21,6 +22,7 @@ jest.setMock('worker-farm', function() { return () => {}; }) .mock('../../Activity'); const Promise = require('promise'); +const SourceMapConsumer = require('source-map').SourceMapConsumer; const Bundler = require('../../Bundler'); 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(); + }); + }); + }); }); diff --git a/react-packager/src/Server/index.js b/react-packager/src/Server/index.js index ecce02fd..10258d4f 100644 --- a/react-packager/src/Server/index.js +++ b/react-packager/src/Server/index.js @@ -14,6 +14,7 @@ const FileWatcher = require('node-haste').FileWatcher; const getPlatformExtension = require('node-haste').getPlatformExtension; const Bundler = require('../Bundler'); const Promise = require('promise'); +const SourceMapConsumer = require('source-map').SourceMapConsumer; const _ = require('lodash'); const declareOpts = require('../lib/declareOpts'); @@ -428,6 +429,9 @@ class Server { } else if (pathname.match(/^\/assets\//)) { this._processAssetsRequest(req, res); return; + } else if (pathname === '/symbolicate') { + this._symbolicate(req, res); + return; } else { next(); return; @@ -480,6 +484,64 @@ class Server { ).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) { res.writeHead(error.status || 500, { 'Content-Type': 'application/json; charset=UTF-8',