From e5bce45095d87f17c6e95483677a20990cf1fa64 Mon Sep 17 00:00:00 2001 From: Sven Slootweg Date: Fri, 18 Nov 2016 14:35:11 +0100 Subject: [PATCH] Initial commit; v1.0.0 --- .gitignore | 4 + .npmignore | 3 + CHANGELOG.md | 3 + README.md | 181 +++++++++++++++++++ gulpfile.js | 18 ++ index.js | 3 + package.json | 43 +++++ screenshot.png | Bin 0 -> 36844 bytes src/daemon/email/create-sender.js | 41 +++++ src/daemon/email/generate-preview.js | 20 ++ src/daemon/error/parse.js | 34 ++++ src/daemon/error/read.js | 23 +++ src/daemon/error/stack/collapse-ignored.js | 21 +++ src/daemon/error/stack/filter-all-modules.js | 16 ++ src/daemon/error/stack/filter-modules.js | 22 +++ src/daemon/error/stack/parse-modules.js | 21 +++ src/daemon/error/stack/parse.js | 19 ++ src/daemon/error/stack/prettify.js | 11 ++ src/daemon/index.js | 64 +++++++ src/daemon/load-configuration.js | 32 ++++ src/daemon/print-error.js | 13 ++ src/library/index.js | 37 ++++ src/library/serialize-error.js | 10 + test.js | 26 +++ 24 files changed, 665 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 gulpfile.js create mode 100644 index.js create mode 100644 package.json create mode 100644 screenshot.png create mode 100644 src/daemon/email/create-sender.js create mode 100644 src/daemon/email/generate-preview.js create mode 100644 src/daemon/error/parse.js create mode 100644 src/daemon/error/read.js create mode 100644 src/daemon/error/stack/collapse-ignored.js create mode 100644 src/daemon/error/stack/filter-all-modules.js create mode 100644 src/daemon/error/stack/filter-modules.js create mode 100644 src/daemon/error/stack/parse-modules.js create mode 100644 src/daemon/error/stack/parse.js create mode 100644 src/daemon/error/stack/prettify.js create mode 100644 src/daemon/index.js create mode 100644 src/daemon/load-configuration.js create mode 100644 src/daemon/print-error.js create mode 100644 src/library/index.js create mode 100644 src/library/serialize-error.js create mode 100644 test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9292a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +/lib/ +test-config.json +errors diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..1938807 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +/node_modules/ +test-config.json +errors diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..49399c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 (November 18, 2016) + +Initial release. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7578fda --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# report-errors + +An easy-to-use tool for tracking unhandled errors. + +* Automatically and immediately e-mails you when an unhandled error occurs in your application. +* Easy to set up. +* Supports both synchronous and asynchronous errors (using Promises). +* Includes both a simplified stacktrace and full error details in the e-mail. +* Supports manual reporting of errors (eg. from error-handling middleware). +* Automatically crashes the process after reporting an error, to prevent data loss or corruption. +* Reporting is handled from *outside* of your application process, using a dedicated daemon - this minimizes the chance of data loss. +* Configurable e-mail subject line. +* Works with any SMTP provider, as well as directly from the server (but read the caveat below). + +An example of an error report: + +![Screenshot of report e-mail](https://git.cryto.net/joepie91/node-report-errors/raw/master/screenshot.png) + +## Crashing processes and safe error handling + +By default, `report-error` will crash your process once it has encountered and reported an error, in the expectation that a service manager will restart it. While this behaviour can be disabled, __I strongly recommend against that__. + +When an unhandled error occurs, that means that your application was not aware of how to handle this error - thus, it also cannot know what application state was affected by this error. The application is now in an *undefined state*, and continuing to run in an undefined state could lead to __data loss, corruption, or security issues__. + +The only safe thing to do after encountering an unhandled error, is for your application to crash as soon as possible, and be restarted cleanly. For this reason, you should leave the default behaviour intact if at all possible, and rely on your service manager (systemd, `forever`, PM2, etc.) to restart the application. + +If you are concerned about downtime, you can run your application in [cluster mode](https://nodejs.org/api/cluster.html), or as multiple processes behind a load balancer. This way, if a single process crashes, new requests will simply be redirected to other processes while your crashed process is restarting. + +## Sending reports without an SMTP provider + +While `report-error` can send reports directly to a specified e-mail address without using an external SMTP server, this is *not recommended* in most cases. Spamfilters will generally distrust these kind of "directly-sent" e-mails, for a variety of reasons. You *can* make it work by explicitly setting a "this is not spam" rule on the receiving side, but it's generally easier to just use an SMTP provider of some sort. + +## License + +[WTFPL](http://www.wtfpl.net/txt/copying/) or [CC0](https://creativecommons.org/publicdomain/zero/1.0/), whichever you prefer. A donation and/or attribution are appreciated, but not required. + +## Donate + +Maintaining open-source projects takes a lot of time, and the more donations I receive, the more time I can dedicate to open-source. If this module is useful to you, consider [making a donation](http://cryto.net/~joepie91/donate.html)! + +You can donate using Bitcoin, PayPal, Flattr, cash-in-mail, SEPA transfers, and pretty much anything else. Thank you! + +## Contributing + +Pull requests welcome. Please make sure your modifications are in line with the overall code style, and ensure that you're editing the files in `src/`, not those in `lib/`. + +Build tool of choice is `gulp`; simply run `gulp` while developing, and it will watch for changes. + +Be aware that by making a pull request, you agree to release your modifications under the licenses stated above. + +## Usage + +This library consists of two parts: + +1. The error handling library +2. The reporting daemon + +The error handling library is purely responsible for catching all unhandled errors in the application, dumping them to a JSON file on disk, and subsequently crashing the process. It does not attempt to do any formatting or post-processing. The reporting daemon picks up the dumped files (by watching the filesystem), formats them into e-mails with attachments, and sends them off. + +### Creating a configuration file + +The reporting daemon uses a single configuration file to determine where to send reports. A typical configuration file might look something like this: + +```json +{ + "errorPath": "/opt/my-project/errors", + "stackFilter": "*", + "metadata": { + "from": "ops@cryto.net", + "to": "admin@cryto.net" + } +} + +``` + +For the sake of this documentation, we'll assume that you've saved this file as `reporter-config.json`, but you can pick any filename - you'll have to provide its path explicitly when you run the error reporter binary, either way. + +Valid options are: + +* __errorPath:__ The directory that your errors are stored in. You will configure this later in the error handling library as well. This can be any path, but generally you'll want it to be an `errors` subdirectory in your project directory. +* __stackFilter:__ What modules to filter out of the simplified stacktraces shown in the e-mail report. This can either be the string `"*"` (to filter out *every* third-party module), or an array of module names to filter. Note that the e-mail will always include a JSON attachment containing the *full* stacktrace - this setting purely affects the e-mail body. +* __subjectFormat:__ The format for the subject line of the report e-mail. In this string, `$type` will be replaced with the error type/name, and `$message` will be replaced with the error message. +* __metadata:__ + * __from:__ The sender address displayed on the e-mail report. + * __to:__ The address to e-mail reports to. +* __smtp:__ *Optional.* The SMTP server to use for sending the e-mail reports. If not configured, e-mails are delivered directly to the recipient's e-mail server (but see the caveat above). + * __hostname:__ The hostname on which the SMTP server can be reached. + * __port:__ The port number that the SMTP server is accessible on. + * __username:__ Your username for the SMTP server. + * __password:__ Your password for the SMTP server. +* __nodemailer:__ *All optional.* Custom options to be passed directly into the underlying `nodemailer` instance. + * __secure:__ Forces SSL/TLS usage. + * __requireTLS:__ Forces STARTTLS usage. + * __ignoreTLS:__ Prevents usage of either SSL/TLS or STARTTLS. + * __tls:__ Custom TLS options to pass to the [`tls` core library](https://nodejs.org/api/tls.html#tls_class_tls_tlssocket). + * __localAddress:__ The local network interface to use for network connections. + * __connectionTimeout:__ How many milliseconds to wait for a connection to be established. + * __greetingTimeout:__ How many milliseconds to wait for the SMTP greeting to be received. + * __socketTimeout:__ How many milliseconds of inactivity to allow, before a connection is closed. + +### Setting up the daemon + +The exact setup instructions will vary depending on what service manager you use, but you should ensure that the following command is run somehow: + +```sh +/path/to/your/project/node_modules/.bin/report-errors /path/to/your/reporter-config.json +``` + +That's it! The daemon will watch the configured error directory, and e-mail you any errors that appear. Make sure that your service manager is configured to restart the process if it crashes. + +You will probably also want to configure your service manager to log the output of the application - if something breaks in the reporting daemon, it will be printed to the terminal. It can't e-mail you the error if the reporter itself is broken! + +### Setting up the library + +Using the library is pretty simple - simply add the following at the start of your application's entry point: + +```javascript +const path = require("path"); +const reportErrors = require("report-errors"); + +// ... + +let errorReporter = reportErrors(path.join(__dirname, "errors")); +``` + +This will store all errors in the `errors` subdirectory relative to your entry point (which is usually, but not always, the root of your project). You should ensure that your reporter configuration file has this same path set as its `errorPath`. + +In some cases, you might want to report unhandled errors manually - for example, from Express error handling middleware. You'd use the `report` method for that, like so: + +```javascript +/* Assuming `app` contains an Express application, and the `errorReporter` has been defined as before... */ + +app.use((error, req, res, next) => { + if (error.statusCode != null && error.statusCode >= 400 && error.statusCode < 500) { + /* This is a client error, such as a 401 or a 403. */ + res.status(error.statusCode).send(error.message); + } else { + /* This is some other kind of error we didn't expect. */ + errorReporter.report(error, { + req: req, + res: res + }); + } +}) +``` + +Note how we're not just passing in the `error`, but also an additional object containing `req` and `res` - this object is the *context* object, and it can contain any JSON-serializable data. It's stored alongside the error, and it can be useful to more easily reproduce an error - for example, in this case, the serialized version of the `req` object will include things like the request method and path. + +## Building your own error processing tools + +While the default error reporting daemon is good enough for most simple deployments, in some cases you may want to develop your own tooling for dealing with the reported errors. All `report-errors` errors are stored on disk in a standardized JSON format: + +* __error:__ The serialized error object. Usually includes at least `name`, `message`, `code`, and `stack` (as well as custom properties), but none of these are guaranteed to exist. The stacktrace is the original string-formatted stacktrace, and it's up to you to parse it as needed. +* __environmentName:__ A brief description of the environment that the error occurred in - operating system, runtime, and so on. As provided by the [`env-name`](https://github.com/vdemedes/env-name) module. +* __environmentInfo:__ A structured set of information about the environment that the error occurred in. As provided by the [`env-info`](https://github.com/vdemedes/env-info) module. +* __hostname:__ The hostname of the system on which the error occurred. +* __context:__ An object containing the "context" that was passed in with the reported error. For an uncaught rejection, this will contain the originating Promise as a `promise` property, but the `context` object may contain *any* kind of context that the reporting code felt was necessary to reproduce the error. This information is meant for later human inspection. + +The JSON attachments in the default reporter's e-mails include the above properties, but also several additional properties: + +* __parsedStack:__ A parsed version of the stacktrace, as produced by the [`stacktrace-parser`](https://www.npmjs.com/package/stacktrace-parser) module. +* __simplifiedStack:__ A *simplified* version of the parsed stacktrace, simplified according to the `stackFilter` option in your reporter configuration. All removed stacktrace items are replaced by an object of the format `{type: "ignored", count: 9}`, where the `count` property indicates *how many* stacktrace items were omitted. + +## API + +### reportErrors(errorPath, [options]) + +Creates a new `errorReporter` instance. + +* __errorPath:__ Path where all errors will be stored. This must be writable by the application, and it must be the same path that the configuration file for the reporting daemon points at. +* __options:__ + * __doNotCrash:__ If set to `true`, it prevents the library from crashing your process after an error is reported. This is *extremely dangerous*, and you should only use it if you are fully aware of the consequences. Defaults to `false`. + +__WARNING:__ Note that *as soon as you create an instance*, it will start intercepting errors, and things like the default `uncaughtException` handler will no longer fire! + +### errorReporter.report(error, [context]) + +Manually reports an `error` with the given `context`. It will be treated the same as any automatically caught errors. + +* __error:__ The error to report. *Must* be an Error object or descendant thereof. +* __context:__ Any data associated with the context in which the error occurred. For example, when reporting an unhandled error from your Express error-handling middleware, you might want to include `req` and `res` here. There is no standard set of keys/values to use here - include whatever information you feel is important to reproduce the error, as long as it's JSON-serializable; circular references are fine. diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..1769501 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,18 @@ +var gulp = require("gulp"); +var presetES2015 = require("@joepie91/gulp-preset-es2015"); + +var source = ["src/**/*.js"] + +gulp.task('babel', function() { + return gulp.src(source) + .pipe(presetES2015({ + basePath: __dirname + })) + .pipe(gulp.dest("lib/")); +}); + +gulp.task("watch", function () { + gulp.watch(source, ["babel"]); +}); + +gulp.task("default", ["babel", "watch"]); \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..f6cb49d --- /dev/null +++ b/index.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require("./lib/library"); diff --git a/package.json b/package.json new file mode 100644 index 0000000..8970635 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "report-errors", + "version": "1.0.0", + "description": "A tool for reporting unexpected errors in your application by e-mail", + "main": "index.js", + "bin": { + "report-errors": "./lib/daemon/index.js" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "http://git.cryto.net/joepie91/node-report-errors.git" + }, + "keywords": [ + "monitoring", + "email", + "error handling", + "errors", + "promises" + ], + "author": "Sven Slootweg", + "license": "WTFPL", + "dependencies": { + "bluebird": "^3.3.3", + "chokidar": "^1.6.1", + "env-info": "^1.0.0", + "env-name": "^1.0.0", + "json-stringify-safe": "^5.0.1", + "nodemailer": "^2.6.4", + "object.omit": "^2.0.1", + "object.pick": "^1.2.0", + "stacktrace-parser": "^0.1.4", + "unhandled-error": "^1.0.0" + }, + "devDependencies": { + "@joepie91/gulp-preset-es2015": "^1.0.1", + "babel-preset-es2015": "^6.6.0", + "create-error": "^0.3.1", + "gulp": "^3.9.1" + } +} diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..ee35b77e3bafc3a37c2538e4e1e33417c81f30f7 GIT binary patch literal 36844 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Ν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 JvFJPB{{ { + return fs.readFileAsync(errorFilePath); + }).then((fileContents) => { + return JSON.parse(fileContents); + }).catch(SyntaxError, (err) => { + if (attempt < maxAttempts) { + /* The JSON hasn't been completely written yet. We'll retry in a bit. */ + return Promise.delay(1000).then(() => { + reportError(errorFilePath, attempt + 1); + }); + } else { + throw new Error(`Could not parse error file ${errorFilePath}, and reached maximum attempts`); + } + }); +}; diff --git a/src/daemon/error/stack/collapse-ignored.js b/src/daemon/error/stack/collapse-ignored.js new file mode 100644 index 0000000..f41cd26 --- /dev/null +++ b/src/daemon/error/stack/collapse-ignored.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = function collapseIgnored(stack) { + let sawIgnored = false; + + return stack.reduce((newStack, stackLine) => { + if (stackLine.type === "ignored") { + if (sawIgnored === false) { + sawIgnored = true; + newStack.push(stackLine); + } else { + newStack[newStack.length - 1].count += 1; + } + } else { + sawIgnored = false; + newStack.push(stackLine); + } + + return newStack; + }, []); +}; diff --git a/src/daemon/error/stack/filter-all-modules.js b/src/daemon/error/stack/filter-all-modules.js new file mode 100644 index 0000000..c724ac2 --- /dev/null +++ b/src/daemon/error/stack/filter-all-modules.js @@ -0,0 +1,16 @@ +'use strict'; + +module.exports = function filterAllModules(stack) { + return stack.map((stackLine) => { + if (stackLine.file.includes("/node_modules/") || stackLine.file.includes("\\node_modules\\") || (!stackLine.file.includes("/") && !stackLine.file.includes("\\"))) { + return { + type: "ignored", + count: 1 + } + } else { + return Object.assign({ + type: "stack" + }, stackLine); + } + }); +}; diff --git a/src/daemon/error/stack/filter-modules.js b/src/daemon/error/stack/filter-modules.js new file mode 100644 index 0000000..8b09113 --- /dev/null +++ b/src/daemon/error/stack/filter-modules.js @@ -0,0 +1,22 @@ +'use strict'; + +const parseModules = require("./parse-modules"); + +module.exports = function filterModules(stack, ignoredModules) { + let newStack = []; + + return stack.map((stackLine) => { + let moduleList = parseModules(stackLine.file); + + if (moduleList.some(module => ignoredModules.includes(module))) { + return { + type: "ignored", + count: 1 + } + } else { + return Object.assign({ + type: "stack" + }, stackLine); + } + }); +}; diff --git a/src/daemon/error/stack/parse-modules.js b/src/daemon/error/stack/parse-modules.js new file mode 100644 index 0000000..27a44a5 --- /dev/null +++ b/src/daemon/error/stack/parse-modules.js @@ -0,0 +1,21 @@ +'use strict'; + +module.exports = function parseModules(filePath) { + if (!filePath.includes("/") && !filePath.includes("\\")) { + return [""]; + } else { + let regex = /(?:\/|\\)node_modules(?:\/|\\)([^\/\\]+)/g; + let match, matches = []; + + while (match = regex.exec(filePath)) { + matches.push(match); + } + + console.log(filePath) + console.log(matches) + + return matches.map((match) => { + return match[1]; + }); + } +}; diff --git a/src/daemon/error/stack/parse.js b/src/daemon/error/stack/parse.js new file mode 100644 index 0000000..30232bf --- /dev/null +++ b/src/daemon/error/stack/parse.js @@ -0,0 +1,19 @@ +'use strict'; + +const stacktraceParser = require("stacktrace-parser"); +const printError = require("../../print-error"); + +module.exports = function parseStacktrace(stacktraceString) { + try { + return stacktraceParser.parse(stacktraceString); + } catch (err) { + printError([ + "WARNING: Unable to parse stacktrace!", + err, + "Original stacktrace string:", + stacktraceString + ]); + + return []; + } +}; diff --git a/src/daemon/error/stack/prettify.js b/src/daemon/error/stack/prettify.js new file mode 100644 index 0000000..897bd41 --- /dev/null +++ b/src/daemon/error/stack/prettify.js @@ -0,0 +1,11 @@ +'use strict'; + +module.exports = function prettifyStack(stack) { + return stack.map((stackLine) => { + if (stackLine.type === "ignored") { + return ` ... omitted ${stackLine.count} lines ...`; + } else { + return `${stackLine.methodName} (line ${stackLine.lineNumber} in ${stackLine.file})` + } + }).join("\n"); +}; diff --git a/src/daemon/index.js b/src/daemon/index.js new file mode 100644 index 0000000..73c02f2 --- /dev/null +++ b/src/daemon/index.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +'use strict'; + +const Promise = require("bluebird"); +const chokidar = require("chokidar"); +const fs = Promise.promisifyAll(require("fs")); +const path = require("path"); +const stacktraceParser = require("stacktrace-parser"); + +const loadConfiguration = require("./load-configuration"); +const printError = require("./print-error"); +const createEmailSender = require("./email/create-sender"); +const generateEmailPreview = require("./email/generate-preview"); +const readError = require("./error/read"); +const parseError = require("./error/parse"); + +let config = loadConfiguration(process.argv[2]); + +let transport = createEmailSender(config); + +function reportError(errorFilePath) { + Promise.try(() => { + return readError(errorFilePath); + }).then((errorData) => { + let error = parseError(errorData.error); + + // FIXME: Strip everything after the first line of the message? + let subjectLine = config.subjectFormat.replace(/\$type/g, error.name).replace(/\$message/g, error.message.replace(/\n/g, " ")); + + return Promise.try(() => { + return transport.sendMail({ + subject: subjectLine, + text: generateEmailPreview({ + environmentName: errorData.environmentName, + hostname: errorData.hostname, + error: error, + }, config.stackFilter), + attachments: [{ + filename: path.basename(errorFilePath), + content: JSON.stringify(Object.assign(errorData, { + parsedStack: error.stack, + simplifiedStack: error.stackWithout("*", true) + }), null, "\t") + }] + }); + }).then(() => { + console.log(`Reported error in ${path.basename(errorFilePath)}: ${error.name} - ${error.message}`); + }); + }).catch((err) => { + printError([ + `ERROR: Unable to parse or report error for ${errorFilePath}.`, + err + ]); + }); +} + +chokidar.watch(config.errorPath, {ignoreInitial: true}) + .on("add", (errorFilePath) => { + reportError(errorFilePath); + }) + .on("ready", () => { + console.log("Listening for new errors..."); + }); diff --git a/src/daemon/load-configuration.js b/src/daemon/load-configuration.js new file mode 100644 index 0000000..4e7a652 --- /dev/null +++ b/src/daemon/load-configuration.js @@ -0,0 +1,32 @@ +'use strict'; + +const fs = require("fs"); + +module.exports = function loadConfiguration(configurationPath) { + if (configurationPath == null) { + console.error("You must specify a configuration file."); + process.exit(1); + } + + let config = JSON.parse(fs.readFileSync(configurationPath)); + + if (config.metadata == null || config.metadata.from == null || config.metadata.to == null) { + console.error("Your configuration file must specify at least a sender (metadata.from) and a recipient (metadata.to)."); + process.exit(1); + } + + if (config.errorPath == null) { + console.error("Your configuration file must specify an errorPath."); + process.exit(1); + } + + if (config.subjectFormat == null) { + config.subjectFormat = "UNHANDLED ERROR: $type - $message" + } + + if (config.stackFilter == null) { + config.stackFilter = "*"; + } + + return config; +}; diff --git a/src/daemon/print-error.js b/src/daemon/print-error.js new file mode 100644 index 0000000..9bc474d --- /dev/null +++ b/src/daemon/print-error.js @@ -0,0 +1,13 @@ +'use strict'; + +module.exports = function printError(errorLines) { + console.error("_______________________________________"); + console.error(""); + + errorLines.forEach((line) => { + console.error(line); + }); + + console.error("_______________________________________"); + console.error(""); +}; diff --git a/src/library/index.js b/src/library/index.js new file mode 100644 index 0000000..3317a67 --- /dev/null +++ b/src/library/index.js @@ -0,0 +1,37 @@ +'use strict'; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const unhandledError = require("unhandled-error"); +const jsonStringifySafe = require("json-stringify-safe"); +const envName = require("env-name"); +const envInfo = require("env-info"); + +const serializeError = require("./serialize-error"); + +module.exports = function createErrorReporter(errorPath, options = {}) { + let unhandledErrorHandler = unhandledError((error, context) => { + console.log(context); + + let stringifiedErrorData = jsonStringifySafe({ + error: serializeError(error), + context: context, + environmentName: envName(), + environmentInfo: envInfo(), + hostname: os.hostname() + }, undefined, "\t", () => {}); + + let errorFilename = `${Date.now()}_${Math.floor(Math.random() * 100000)}.json`; + + fs.writeFileSync(path.join(errorPath, errorFilename), stringifiedErrorData); + + if (options.handler != null) { + options.handler(error, context); + } + }, {doNotCrash: options.doNotCrash}); + + return { + report: unhandledErrorHandler.report.bind(unhandledErrorHandler) + } +} diff --git a/src/library/serialize-error.js b/src/library/serialize-error.js new file mode 100644 index 0000000..6ba9546 --- /dev/null +++ b/src/library/serialize-error.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = function serializeError(error) { + /* The `name`, `message` and `stack` properties are not always enumerable, so we need to add them explicitly. */ + return Object.assign({}, error, { + stack: error.stack, + message: error.message, + name: error.name + }); +}; diff --git a/test.js b/test.js new file mode 100644 index 0000000..ad5db50 --- /dev/null +++ b/test.js @@ -0,0 +1,26 @@ +'use strict'; + +const Promise = require("bluebird"); +const path = require("path"); +const createError = require("create-error"); +const reportErrors = require("./"); + +let TestingError = createError("TestingError", { + someProperty: "foo" +}); + +let errorReporter = reportErrors(path.join(__dirname, "errors")); + +setTimeout(() => { + console.log("bar"); +}, 500); + +Promise.try(() => { + console.log("foo"); + throw new TestingError("Request limit exceeded, API is now on fire", { + qux: { + quz: 1, + quack: [2, false] + } + }); +});