├── Gemfile ├── lang └── en.yml ├── config └── routes.rb ├── test ├── test_helper.rb ├── functional │ └── notices_controller_test.rb └── unit │ └── hoptoad_v2_notice_test.rb ├── lib └── redmine_hoptoad_server │ └── patches │ └── issue_patch.rb ├── init.rb ├── app ├── models │ └── hoptoad_v2_notice.rb └── controllers │ └── notices_controller.rb └── README.rdoc /Gemfile: -------------------------------------------------------------------------------- 1 | group :test do 2 | gem 'airbrake', '< 5.0' 3 | end 4 | -------------------------------------------------------------------------------- /lang/en.yml: -------------------------------------------------------------------------------- 1 | # English strings go here 2 | my_label: "My label" 3 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | 2 | # API v1 (hoptoad) 3 | post 'notices', :to => 'notices#create' 4 | 5 | # API v2 (hoptoad / airbrake, xml based) 6 | post 'notifier_api/v2/notices', :to => 'notices#create_v2' 7 | 8 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the normal Rails helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../test/test_helper') 3 | 4 | class ActionController::TestCase 5 | 6 | def raw_post(action, params, body = '') 7 | @request.env['RAW_POST_DATA'] = body 8 | response = post(action, params) 9 | @request.env.delete('RAW_POST_DATA') 10 | response 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /lib/redmine_hoptoad_server/patches/issue_patch.rb: -------------------------------------------------------------------------------- 1 | require_dependency 'issue' 2 | 3 | module RedmineHoptoadServer 4 | module Patches 5 | module IssuePatch 6 | def self.included(base) 7 | base.class_eval do 8 | attr_accessor :skip_notification 9 | def skip_notification? 10 | @skip_notification == true 11 | end 12 | 13 | def send_notification_with_skip_notification 14 | send_notification_without_skip_notification unless skip_notification? 15 | end 16 | alias_method_chain :send_notification, :skip_notification 17 | end 18 | end 19 | end 20 | end 21 | end 22 | 23 | Issue.send(:include, RedmineHoptoadServer::Patches::IssuePatch) unless Issue.included_modules.include?(RedmineHoptoadServer::Patches::IssuePatch) 24 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | Redmine::Plugin.register :redmine_hoptoad_server do 2 | name 'Redmine Hoptoad Server plugin' 3 | author 'Jan Schulz-Hofen, Planio GmbH' 4 | author_url 'https://plan.io/team/#jan' 5 | description 'Turns Redmine into an Airbrake/Hoptoad compatible server, i.e. an API provider which can be used with the Airbrake gem or the hoptoad_notifier plugin.' 6 | url 'http://github.com/yeah/redmine_hoptoad_server' 7 | version '1.0.0' 8 | hidden(true) if respond_to?(:hidden) # hide plugin in Planio 9 | 10 | requires_redmine :version_or_higher => '2.4.0' 11 | end 12 | 13 | begin 14 | require 'nokogiri' 15 | rescue LoadError 16 | Rails.logger.error "Nokogiri gem not found, parsing hoptoad API v2 requests will be sub-optimal" 17 | end 18 | 19 | Rails.configuration.to_prepare do 20 | require_dependency 'redmine_hoptoad_server/patches/issue_patch' 21 | end 22 | 23 | -------------------------------------------------------------------------------- /app/models/hoptoad_v2_notice.rb: -------------------------------------------------------------------------------- 1 | class HoptoadV2Notice 2 | attr_reader :redmine_params 3 | 4 | def initialize(data) 5 | xml = Nokogiri::XML(data) 6 | @redmine_params = YAML.load(xml.xpath('//api-key').first.content, :safe => true) rescue {} 7 | 8 | error = { 9 | 'class' => (xml.xpath('//error/class').first.content rescue nil), 10 | 'message' => (xml.xpath('//error/message').first.content rescue nil), 11 | 'backtrace' => [] 12 | } 13 | xml.xpath('//error/backtrace/line').each do |line| 14 | error['backtrace'] << { 'number' => line['number'], 'file' => line['file'], 'method' => line['method'] } 15 | end 16 | 17 | env = {} 18 | xml.xpath('//server-environment/*').each{|element| env[element.name] = element.content} 19 | 20 | req = { 21 | 'params' => {}, 22 | 'cgi-data' => {} 23 | } 24 | xml.xpath('//request/*').each do |element| 25 | case element.name 26 | when 'params', 'cgi-data' 27 | req[element.name] = parse_key_values(element.xpath('var')) 28 | else 29 | req[element.name] = element.content 30 | end 31 | end 32 | 33 | @notice = { 34 | 'error' => error, 35 | 'server_environment' => env, 36 | 'request' => req 37 | } 38 | end 39 | 40 | def [](key) 41 | @notice[key] 42 | end 43 | 44 | private 45 | 46 | def parse_key_values(xml) 47 | {}.tap do |result| 48 | xml.each do |element| 49 | result[element['key']] = element.content 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/functional/notices_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.dirname(__FILE__) + '/../test_helper' 2 | 3 | class NoticesControllerTest < ActionController::TestCase 4 | fixtures :projects, :users, :trackers, :projects_trackers, :enumerations, :issue_statuses 5 | 6 | def setup 7 | Setting.mail_handler_api_key = 'asdfghjk' 8 | @project = Project.find :first 9 | @tracker = @project.trackers.first 10 | end 11 | 12 | test 'should create an issue with journal entry' do 13 | assert_difference "Issue.count", 1 do 14 | assert_difference "Journal.count", 1 do 15 | raw_post :create_v2, {}, create_error.to_xml 16 | end 17 | end 18 | assert_response :success 19 | assert issue = Issue.where("subject like ?", 20 | 'RuntimeError in plugins/redmine_hoptoad_server/test/functional/notices_controller_test.rb%' 21 | ).first 22 | assert_equal(1, issue.journals.size) 23 | assert_equal(6, issue.priority_id) 24 | assert occurences_field = IssueCustomField.find_by_name('# Occurences') 25 | assert occurences_value = issue.custom_value_for(occurences_field) 26 | assert_equal('1', occurences_value.value) 27 | 28 | 29 | assert_no_difference 'Issue.count' do 30 | assert_difference "Journal.count", 1 do 31 | raw_post :create_v2, {}, create_error.to_xml 32 | end 33 | end 34 | occurences_value.reload 35 | assert_equal('2', occurences_value.value) 36 | end 37 | 38 | test "should render 404 for non existing project" do 39 | assert_no_difference "Issue.count" do 40 | assert_no_difference "Journal.count" do 41 | raw_post :create_v2, {}, create_error(:project => 'Unknown').to_xml 42 | end 43 | end 44 | assert_response 404 45 | end 46 | 47 | test "should render 404 for non existing tracker" do 48 | assert_no_difference "Issue.count" do 49 | assert_no_difference "Journal.count" do 50 | raw_post :create_v2, {}, create_error(:tracker => 'Unknown').to_xml 51 | end 52 | end 53 | assert_response 404 54 | end 55 | 56 | 57 | def create_error(options = {}) 58 | raise 'test' 59 | rescue 60 | return Airbrake.send(:build_notice_for, 61 | $!, 62 | :api_key => { 63 | :project => @project.identifier, 64 | :tracker => @tracker.name, 65 | :api_key => 'asdfghjk', 66 | :priority => 6 67 | }.merge(options).to_yaml) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = Hoptoad Server 2 | 3 | This is a simple Redmine plugin that makes Redmine act like an Airbrake (http://airbrake.io/) 4 | (formerly known as Hoptoad) server. All exceptions caught and sent by HoptoadNotifier or Airbrake 5 | client libraries will create or update an issue in Redmine. 6 | 7 | == Installation & Configuration 8 | 9 | Just install the Plugin following the general Redmine plugin installation instructions at 10 | http://www.redmine.org/wiki/redmine/Plugins. 11 | 12 | Then, go to Administration -> Settings -> Incoming emails in your Redmine and generate an API key. 13 | 14 | Now, install Airbrake following the excellent instructions at 15 | http://github.com/airbrake/airbrake. 16 | 17 | The Redmine Hoptoad Server supports the older Hoptoad API (v1) as well as Hoptoad v2 / Airbrake. 18 | 19 | 20 | When it comes to creating your config/initializers/airbrake.rb file, deviate from the standard and put in something like this: 21 | 22 | 23 | Airbrake.configure do |config| 24 | config.api_key = {:project => 'my_redmine_project_identifier', # the identifier you specified for your project in Redmine 25 | :tracker => 'Bug', # the name of your Tracker of choice in Redmine 26 | :api_key => 'my_redmine_api_key', # the key you generated before in Redmine (NOT YOUR HOPTOAD API KEY!) 27 | :category => 'Development', # the name of a ticket category (optional.) 28 | :assigned_to => 'admin', # the login of a user the ticket should get assigned to by default (optional.) 29 | :priority => 5, # the default priority (use a number, not a name. optional.) 30 | :environment => 'staging', # application environment, gets prepended to the issue's subject and is stored as a custom issue field. useful to distinguish errors on a test system from those on the production system (optional). 31 | :repository_root => '/some/path' # this optional argument overrides the project wide repository root setting (see below). 32 | }.to_yaml 33 | config.host = 'my_redmine_host.com' # the hostname your Redmine runs at 34 | config.port = 443 # the port your Redmine runs at 35 | config.secure = true # sends data to your server via SSL (optional.) 36 | end 37 | 38 | You're done. You can start receiving your Exceptions in Redmine! 39 | 40 | === More Configuration (please read on!) 41 | 42 | After you received your first exception in Redmine, you will notice two new custom fields 43 | in the project(s) you've received the exceptions for. Those are *Backtrace* *filter* 44 | and *Repository* *root*. 45 | 46 | ==== Backtrace filter 47 | 48 | If you'd like to (and we really recommend you do!) filter the backtraces that Notifier reports, 49 | you can add comma separated strings to that field. Every line in a backtrace will be scanned 50 | against those strings and matching lines *will* *be* *removed*. I usually set my filter 51 | to "[GEM_ROOT]", but if you're using plugins which tend to clutter up your backtraces, you 52 | might want to include those as well. Like this for example: 53 | "[GEM_ROOT],[RAILS_ROOT]/vendor/plugins/newrelic_rpm". 54 | 55 | ==== Repository root 56 | 57 | All Issues created will have a source link in their description which -- provided that you have 58 | your source repository linked to your Redmine project -- leads you directly to the file and 59 | line in your code that has caused the exception. Your repository structure most likely won't 60 | match the structure of your deployed code, so you can add an additional repository root. 61 | Just use "trunk" for a general SVN setup for instance. 62 | 63 | You may use the :repository_root option in your application's airbrake.rb to override this 64 | setting with a custom value. This is helful in case you have multiple applications in the same 65 | repository reporting errors to the same Redmine project. 66 | 67 | ==== Dependencies 68 | 69 | Safe YAML (https://github.com/dtao/safe_yaml). 70 | For parsing Airbrake v2 requests the plugin also depends on Nokogiri. 71 | 72 | Add to your Redmine's Gemfile.local: 73 | 74 | gem 'safe_yaml' 75 | gem 'nokogiri' 76 | 77 | == License 78 | 79 | MIT 80 | 81 | == Author 82 | 83 | Jan Schulz-Hofen, Planio GmbH (http://plan.io) 84 | -------------------------------------------------------------------------------- /app/controllers/notices_controller.rb: -------------------------------------------------------------------------------- 1 | require 'pp' 2 | 3 | class NoticesController < ActionController::Base 4 | 5 | before_filter :check_enabled 6 | before_filter :find_or_create_custom_fields 7 | 8 | unloadable 9 | 10 | TRACE_FILTERS = [ 11 | /^On\sline\s#\d+\sof/, 12 | /^\d+:/ 13 | ] 14 | 15 | def create_v2 16 | #logger.debug {"received v2 request:\n#{@notice.inspect}\nwith redmine_params:\n#{@redmine_params.inspect}"} 17 | create_or_update_issue @redmine_params, @notice 18 | end 19 | 20 | def create 21 | #logger.debug {"received v1 request:\n#{@notice.inspect}\nwith redmine_params:\n#{@redmine_params.inspect}"} 22 | notice = v2_notice_hash(@notice) 23 | #logger.debug {"transformed arguments:\n#{notice.inspect}"} 24 | create_or_update_issue @redmine_params, notice 25 | end 26 | 27 | private 28 | 29 | def create_or_update_issue(redmine_params, notice) 30 | # retrieve redmine objects referenced in redmine_params 31 | 32 | # project 33 | unless project = Project.find_by_identifier(redmine_params["project"]) 34 | msg = "could not log error, project #{redmine_params["project"]} not found." 35 | Rails.logger.error msg 36 | render :text => msg, :status => 404 and return 37 | end 38 | 39 | # tracker 40 | unless tracker = project.trackers.find_by_name(redmine_params["tracker"]) 41 | msg = "could not log error, tracker #{redmine_params["tracker"]} not found." 42 | Rails.logger.error msg 43 | render :text => msg, :status => 404 and return 44 | end 45 | 46 | # user 47 | author = User.find_by_login(redmine_params["author"]) || User.anonymous 48 | 49 | # error class and message 50 | error_class = notice['error']['class'].to_s 51 | error_message = notice['error']['message'] 52 | 53 | # build filtered backtrace 54 | backtrace = notice['error']['backtrace'] rescue [] 55 | filtered_backtrace = filter_backtrace project, backtrace 56 | error_line = filtered_backtrace.first 57 | 58 | # build subject by removing method name and '[RAILS_ROOT]', make sure it fits in a varchar 59 | subject = redmine_params["environment"] ? "[#{redmine_params["environment"]}] " : "" 60 | subject << error_class 61 | subject << " in #{cleanup_path( error_line['file'] )[0,(250-subject.length)]}:#{error_line['number']}" if error_line 62 | 63 | # build description including a link to source repository 64 | description = "Redmine Notifier reported an Error" 65 | unless filtered_backtrace.blank? 66 | repo_root = redmine_params["repository_root"] 67 | repo_root ||= project.custom_value_for(@repository_root_field).value.gsub(/\/$/,'') rescue nil 68 | description << " related to source:#{repo_root}/#{cleanup_path error_line['file']}#L#{error_line['number']}" 69 | end 70 | 71 | issue = Issue.find_by_subject_and_project_id_and_tracker_id_and_author_id(subject, project.id, tracker.id, author.id) 72 | if issue.nil? 73 | # new issue 74 | issue = Issue.new(:subject => subject, :project_id => project.id, :tracker_id => tracker.id, :author_id => author.id) 75 | 76 | # set standard redmine issue fields 77 | issue.category = IssueCategory.find_by_name(redmine_params["category"]) unless redmine_params["category"].blank? 78 | issue.assigned_to = (User.find_by_login(redmine_params["assigned_to"]) || Group.find_by_lastname(redmine_params["assigned_to"])) unless redmine_params["assigned_to"].blank? 79 | issue.priority_id = redmine_params["priority"].blank? ? 80 | IssuePriority.default.id : 81 | redmine_params["priority"] 82 | issue.description = description 83 | 84 | ensure_project_has_fields(project) 85 | ensure_tracker_has_fields(tracker) 86 | 87 | # set custom field error class 88 | cf_values = { @error_class_field.id => error_class, 89 | @occurences_field.id => 1 } 90 | unless redmine_params["environment"].blank? 91 | cf_values[@environment_field.id] = redmine_params["environment"] 92 | end 93 | issue.custom_field_values = cf_values 94 | issue.skip_notification = true 95 | issue.save! 96 | else 97 | # increment occurences custom field 98 | if value = issue.custom_value_for(@occurences_field) 99 | value.update_attribute :value, (value.value.to_i + 1).to_s 100 | else 101 | issue.custom_values.create!(:value => 1, :custom_field => @occurences_field) 102 | end 103 | end 104 | 105 | 106 | # create the journal entry, update issue attributes 107 | retried_once = false # we retry once in case of a StaleObjectError 108 | begin 109 | issue = Issue.find issue.id # otherwise the save below resets the custom value from above. Also should reduce the chance to run into the staleobject problem. 110 | # update journal 111 | text = "h4. Error message\n\n
#{error_message}"
112 | text << "\n\nh4. Filtered backtrace\n\n#{format_backtrace(filtered_backtrace)}" unless filtered_backtrace.blank?
113 | text << "\n\nh4. Request\n\n#{format_hash notice['request']}" unless notice['request'].blank?
114 | text << "\n\nh4. Session\n\n#{format_hash notice['session']}" unless notice['session'].blank?
115 | unless (env = (notice['server_environment'] || notice['environment'])).blank?
116 | text << "\n\nh4. Environment\n\n#{format_hash env}"
117 | end
118 | text << "\n\nh4. Full backtrace\n\n#{format_backtrace backtrace}" unless backtrace.blank?
119 | journal = issue.init_journal author, text
120 |
121 | # reopen issue if needed
122 | if issue.status.blank? or issue.status.is_closed?
123 | issue.status = IssueStatus.find(:first, :conditions => {:is_default => true}, :order => 'position ASC')
124 | end
125 |
126 | issue.save!
127 | rescue ActiveRecord::StaleObjectError
128 | if retried_once
129 | Rails.logger.error "airbrake server: failed to update issue #{issue.id} for the second time, giving up."
130 | else
131 | retried_once = true
132 | retry
133 | end
134 | end
135 | render :status => 200, :text => "Received bug report.\n