├── .gitignore ├── .npmignore ├── Gruntfile.coffee ├── LICENSE ├── README.md ├── bin └── mini-breakpad-server ├── package.json ├── src ├── app.coffee ├── cache.coffee ├── database.coffee ├── reader.coffee ├── record.coffee ├── saver.coffee └── webhook.coffee └── views ├── index.jade ├── layout.jade └── view.jade /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | /pool 4 | *.swp 5 | *.log 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /src 3 | /pool 4 | *.swp 5 | .npmignore 6 | .gitignore 7 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | grunt.initConfig 3 | pkg: grunt.file.readJSON('package.json') 4 | 5 | coffee: 6 | glob_to_multiple: 7 | expand: true 8 | cwd: 'src' 9 | src: ['*.coffee'] 10 | dest: 'lib' 11 | ext: '.js' 12 | 13 | coffeelint: 14 | options: 15 | max_line_length: 16 | level: 'ignore' 17 | 18 | src: ['src/**/*.coffee'] 19 | 20 | grunt.loadNpmTasks('grunt-contrib-coffee') 21 | grunt.loadNpmTasks('grunt-shell') 22 | grunt.loadNpmTasks('grunt-coffeelint') 23 | grunt.registerTask('lint', ['coffeelint']) 24 | grunt.registerTask('default', ['coffee', 'lint']) 25 | grunt.registerTask 'clean', -> 26 | rm = require('rimraf').sync 27 | rm('lib') 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 GitHub Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mini-breakpad-server 2 | 3 | Minimum collecting server for crash reports sent by 4 | [google-breakpad](https://code.google.com/p/google-breakpad/). 5 | 6 | 7 | ## Features 8 | 9 | * No requirement for setting up databases or web servers. 10 | * Collecting crash reports with minidump files. 11 | * Simple web interface for viewing translated crash reports. 12 | 13 | ## Run 14 | 15 | * `npm install .` 16 | * `grunt` 17 | * Put your breakpad symbols under `pool/symbols/PRODUCT_NAME` 18 | * `node lib/app.js` 19 | -------------------------------------------------------------------------------- /bin/mini-breakpad-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require(__dirname + '/../lib/app.js') 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-breakpad-server", 3 | "description": "Minimum breakpad crash reports collecting server", 4 | "version": "0.2.0", 5 | "bin": { 6 | "mini-breakpad-server": "bin/mini-breakpad-server" 7 | }, 8 | "scripts": { 9 | "prepublish": "grunt coffee", 10 | "postinstall": "grunt", 11 | "start": "node lib/app.js" 12 | }, 13 | "dependencies": { 14 | "body-parser": "^1.16.1", 15 | "decompress-zip": "0.0.4", 16 | "dirty": "0.9.7", 17 | "express": "^4.14.1", 18 | "express-json": "^1.0.0", 19 | "formidable": "~1.0.14", 20 | "fs-plus": "0.10.0", 21 | "github-releases": "0.1.x", 22 | "glob": "3.x", 23 | "grunt": "^0.4.5", 24 | "jade": "~0.35.0", 25 | "method-override": "^2.3.5", 26 | "minidump": "0.3.0", 27 | "mkdirp": "~0.3.5", 28 | "node-uuid": "~1.4.1", 29 | "temp": "0.7.0", 30 | "wrench": "1.5.x" 31 | }, 32 | "devDependencies": { 33 | "coffee-script": "~1.6.2", 34 | "grunt": "~0.4.1", 35 | "grunt-contrib-coffee": "~0.6.6", 36 | "grunt-coffeelint": "~0.0.6", 37 | "grunt-cli": "~0.1.7", 38 | "grunt-shell": "~0.2.2", 39 | "rimraf": "~2.1.4" 40 | }, 41 | "licenses": [ 42 | { 43 | "type": "MIT", 44 | "url": "http://github.com/atom/mini-breakpad-server/raw/master/LICENSE" 45 | } 46 | ], 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/atom/mini-breakpad-server.git" 50 | }, 51 | "bugs": { 52 | "url": "https://github.com/atom/mini-breakpad-server/issues" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app.coffee: -------------------------------------------------------------------------------- 1 | bodyParser = require 'body-parser' 2 | methodOverride = require('method-override') 3 | path = require 'path' 4 | express = require 'express' 5 | reader = require './reader' 6 | saver = require './saver' 7 | Database = require './database' 8 | WebHook = require './webhook' 9 | 10 | app = express() 11 | webhook = new WebHook 12 | 13 | db = new Database 14 | db.on 'load', -> 15 | port = process.env.MINI_BREAKPAD_SERVER_PORT ? 1127 16 | app.listen port 17 | console.log "Listening on port #{port}" 18 | 19 | app.set 'views', path.resolve(__dirname, '..', 'views') 20 | app.set 'view engine', 'jade' 21 | app.use bodyParser.json() 22 | app.use bodyParser.urlencoded({extended: true}) 23 | app.use methodOverride() 24 | app.use (err, req, res, next) -> 25 | res.send 500, "Bad things happened:
#{err.message}" 26 | 27 | app.post '/webhook', (req, res, next) -> 28 | webhook.onRequest req 29 | 30 | console.log 'webhook requested', req.body.repository.full_name 31 | res.end() 32 | 33 | app.post '/post', (req, res, next) -> 34 | saver.saveRequest req, db, (err, filename) -> 35 | return next err if err? 36 | 37 | console.log 'saved', filename 38 | res.send path.basename(filename) 39 | res.end() 40 | 41 | root = 42 | if process.env.MINI_BREAKPAD_SERVER_ROOT? 43 | "#{process.env.MINI_BREAKPAD_SERVER_ROOT}/" 44 | else 45 | '' 46 | 47 | app.get "/#{root}", (req, res, next) -> 48 | res.render 'index', title: 'Crash Reports', records: db.getAllRecords() 49 | 50 | app.get "/#{root}view/:id", (req, res, next) -> 51 | db.restoreRecord req.params.id, (err, record) -> 52 | return next err if err? 53 | 54 | reader.getStackTraceFromRecord record, (err, report) -> 55 | return next err if err? 56 | fields = record.fields 57 | res.render 'view', {title: 'Crash Report', report, fields} 58 | -------------------------------------------------------------------------------- /src/cache.coffee: -------------------------------------------------------------------------------- 1 | cache = {} 2 | 3 | module.exports = 4 | get: (id) -> cache[id] 5 | set: (id, data) -> cache[id] = data 6 | has: (id) -> cache.hasOwnProperty id 7 | -------------------------------------------------------------------------------- /src/database.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | dirty = require 'dirty' 3 | mkdirp = require 'mkdirp' 4 | {EventEmitter} = require 'events' 5 | Record = require './record' 6 | 7 | class Database extends EventEmitter 8 | db: null 9 | 10 | # Public: Create or open a Database with path to {filename} 11 | constructor: (filename=path.join('pool', 'database', 'dirty', 'db')) -> 12 | dist = path.resolve filename, '..' 13 | mkdirp dist, (err) => 14 | throw new Error("Cannot create directory: #{dist}") if err? 15 | 16 | @db = dirty filename 17 | @db.on 'load', @emit.bind(this, 'load') 18 | 19 | # Public: Saves a record to database. 20 | saveRecord: (record, callback) -> 21 | @db.set record.id, record.serialize() 22 | callback null 23 | 24 | # Public: Restore a record from database according to its id. 25 | restoreRecord: (id, callback) -> 26 | raw = @db.get(id) 27 | return callback new Error("Record is not in database") unless raw? 28 | 29 | callback null, Record.unserialize(id, @db.get(id)) 30 | 31 | # Public: Returns all records as an array. 32 | getAllRecords: -> 33 | records = [] 34 | @db.forEach (id, record) -> records.push Record.unserialize(id, record) 35 | records.reverse() 36 | 37 | module.exports = Database 38 | -------------------------------------------------------------------------------- /src/reader.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | minidump = require 'minidump' 3 | cache = require './cache' 4 | 5 | module.exports.getStackTraceFromRecord = (record, callback) -> 6 | return callback(null, cache.get(record.id)) if cache.has record.id 7 | 8 | symbolPaths = [ path.join 'pool', 'symbols' ] 9 | minidump.walkStack record.path, symbolPaths, (err, report) -> 10 | cache.set record.id, report unless err? 11 | callback err, report 12 | -------------------------------------------------------------------------------- /src/record.coffee: -------------------------------------------------------------------------------- 1 | path = require 'path' 2 | formidable = require 'formidable' 3 | uuid = require 'node-uuid' 4 | 5 | class Record 6 | id: null 7 | time: null 8 | path: null 9 | product: null 10 | version: null 11 | fields: null 12 | 13 | constructor: ({@id, @time, @path, @sender, @product, @version, @fields}) -> 14 | @id ?= uuid.v4() 15 | @time ?= new Date 16 | 17 | # Public: Parse web request to get the record. 18 | @createFromRequest: (req, callback) -> 19 | form = new formidable.IncomingForm() 20 | form.parse req, (error, fields, files) -> 21 | unless files.upload_file_minidump?.name? 22 | return callback new Error('Invalid breakpad upload') 23 | 24 | record = new Record 25 | path: files.upload_file_minidump.path 26 | sender: {ua: req.headers['user-agent'], ip: Record.getIpAddress(req)} 27 | product: fields.prod 28 | version: fields.ver 29 | fields: fields 30 | callback(null, record) 31 | 32 | # Public: Restore a Record from raw representation. 33 | @unserialize: (id, representation) -> 34 | new Record 35 | id: id 36 | time: new Date(representation.time) 37 | path: representation.path 38 | sender: representation.sender 39 | product: representation.fields.prod 40 | version: representation.fields.ver 41 | fields: representation.fields 42 | 43 | # Private: Gets the IP address from request. 44 | @getIpAddress: (req) -> 45 | req.headers['x-forwarded-for'] || req.connection.remoteAddress 46 | 47 | # Public: Returns the representation to be stored in database. 48 | serialize: -> 49 | time: @time.getTime(), path: @path, sender: @sender, fields: @fields 50 | 51 | module.exports = Record 52 | -------------------------------------------------------------------------------- /src/saver.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs-plus' 2 | path = require 'path' 3 | mkdirp = require 'mkdirp' 4 | Record = require './record' 5 | 6 | exports.saveRequest = (req, db, callback) -> 7 | Record.createFromRequest req, (err, record) -> 8 | return callback new Error("Invalid breakpad request") if err? 9 | 10 | dist = "pool/files/minidump" 11 | mkdirp dist, (err) -> 12 | return callback new Error("Cannot create directory: #{dist}") if err? 13 | 14 | filename = path.join dist, record.id 15 | fs.copy record.path, filename, (err) -> 16 | return callback new Error("Cannot create file: #{filename}") if err? 17 | 18 | record.path = filename 19 | db.saveRecord record, (err) -> 20 | return callback new Error("Cannot save record to database") if err? 21 | 22 | callback null, filename 23 | -------------------------------------------------------------------------------- /src/webhook.coffee: -------------------------------------------------------------------------------- 1 | fs = require 'fs-plus' 2 | glob = require 'glob' 3 | mkdirp = require 'mkdirp' 4 | path = require 'path' 5 | temp = require 'temp' 6 | os = require 'os' 7 | wrench = require 'wrench' 8 | DecompressZip = require 'decompress-zip' 9 | GitHub = require 'github-releases' 10 | 11 | temp.track() 12 | 13 | class WebHook 14 | constructor: -> 15 | 16 | onRequest: (req) -> 17 | event = req.headers['x-github-event'] 18 | payload = req.body 19 | 20 | return unless event is 'release' and payload.action is 'published' 21 | @downloadAssets payload 22 | 23 | downloadAssets: (payload) -> 24 | github = new GitHub 25 | repo: payload.repository.full_name 26 | token: process.env.MINI_BREAKPAD_SERVER_TOKEN 27 | 28 | for asset in payload.release.assets when /sym/.test asset.name 29 | do (asset) => 30 | dir = temp.mkdirSync() 31 | filename = path.join dir, asset.name 32 | github.downloadAsset asset, (error, stream) => 33 | if error? 34 | console.log 'Failed to download', asset.name, error 35 | @cleanup dir 36 | return 37 | file = fs.createWriteStream filename 38 | stream.on 'end', @extractFile.bind(this, dir, filename) 39 | stream.pipe file 40 | 41 | extractFile: (dir, filename) -> 42 | targetDirectory = "#{filename}-unzipped" 43 | unzipper = new DecompressZip filename 44 | unzipper.on 'error', (error) => 45 | console.log 'Failed to decompress', filename, error 46 | @cleanup dir 47 | unzipper.on 'extract', => 48 | fs.closeSync unzipper.fd 49 | fs.unlinkSync filename 50 | @copySymbolFiles dir, targetDirectory 51 | unzipper.extract path: targetDirectory 52 | 53 | copySymbolFiles: (dir, targetDirectory) -> 54 | glob '*.breakpad.syms', cwd: targetDirectory, (error, dirs) => 55 | if error? 56 | console.log 'Failed to find breakpad symbols in', targetDirectory, error 57 | @cleanup dir 58 | return 59 | 60 | symbolsDirectory = path.join 'pool', 'symbols' 61 | for symbol in dirs 62 | fs.copySync path.join(targetDirectory, symbol), symbolsDirectory 63 | @cleanup dir 64 | 65 | cleanup: (dir) -> 66 | wrench.rmdirSyncRecursive dir, true 67 | 68 | module.exports = WebHook 69 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= title 5 | ol 6 | each record in records 7 | li 8 | a(href='view/' + record.id) 9 | = 'v' + record.version + ' ' + record.time.toLocaleString() 10 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype 5 2 | html 3 | head 4 | title= title 5 | body 6 | block content 7 | -------------------------------------------------------------------------------- /views/view.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | pre 5 | for v, k in fields 6 | = k + ": " + v + "\n" 7 | pre= report 8 | --------------------------------------------------------------------------------