redmine_airbrake_backend 0.4.3 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,15 @@
1
+ # Controller for airbrake notices
2
+ class AirbrakeReportController < ::AirbrakeController
3
+ prepend_before_filter :parse_json_request
4
+
5
+ accept_api_auth :report
6
+
7
+ # Handle airbrake reports
8
+ def report
9
+ render json: {
10
+ report: {
11
+ id: (@results.first[:hash] rescue nil)
12
+ }
13
+ }
14
+ end
15
+ end
@@ -1,5 +1,7 @@
1
- module AirbrakeHelper
1
+ require 'htmlentities'
2
2
 
3
+ module AirbrakeHelper
4
+ # Wiki markup for a table
3
5
  def format_table(data)
4
6
  lines = []
5
7
  data.each do |key, value|
@@ -9,6 +11,7 @@ module AirbrakeHelper
9
11
  lines.join("\n")
10
12
  end
11
13
 
14
+ # Wiki markup for logs
12
15
  def format_log(data)
13
16
  lines = []
14
17
  data.each do |log|
@@ -18,41 +21,63 @@ module AirbrakeHelper
18
21
  lines.join("\n")
19
22
  end
20
23
 
24
+ # Wiki markup for a list item
21
25
  def format_list_item(name, value)
22
26
  return '' if value.blank?
27
+
23
28
  "* *#{name}:* #{value}"
24
29
  end
25
30
 
31
+ # Wiki markup for backtrace element with link to repository if possible
26
32
  def format_backtrace_element(element)
27
33
  @htmlentities ||= HTMLEntities.new
28
34
 
29
35
  repository = repository_for_backtrace_element(element)
30
36
 
31
37
  if repository.blank?
32
- file = "@#{@htmlentities.decode(element[:file])}:#{element[:number]}@"
33
- elsif repository.identifier.blank?
34
- file = "source:\"#{@htmlentities.decode(element[:file][14..-1])}#L#{element[:number]}\""
38
+ if element[:number].blank?
39
+ markup = "@#{@htmlentities.decode(element[:file])}@"
40
+ else
41
+ markup = "@#{@htmlentities.decode(element[:file])}:#{element[:number]}@"
42
+ end
35
43
  else
36
- file = "source:\"#{repository.identifier}|#{@htmlentities.decode(element[:file][14..-1])}#L#{element[:number]}\""
44
+ filename = @htmlentities.decode(filename_for_backtrace_element(element))
45
+
46
+ if repository.identifier.blank?
47
+ markup = "source:\"#{filename}#L#{element[:number]}\""
48
+ else
49
+ markup = "source:\"#{repository.identifier}|#{filename}#L#{element[:number]}\""
50
+ end
37
51
  end
38
52
 
39
- file + " in ??<notextile>#{@htmlentities.decode(element[:method])}</notextile>??"
53
+ markup + " in ??<notextile>#{@htmlentities.decode(element[:method])}</notextile>??"
40
54
  end
41
55
 
42
56
  private
43
57
 
44
58
  def repository_for_backtrace_element(element)
45
- if element[:file].start_with?('[PROJECT_ROOT]')
46
- file = element[:file][14..-1]
47
- if @notice.params.key?(:repository)
48
- r = @project.repositories.where(identifier: (@notice.params[:repository] || '')).first
49
- return r if r.present? && r.entry(file)
50
- else
51
- return @project.repositories.select{|r| r.entry(file)}.first
52
- end
59
+ return nil unless element[:file].start_with?('[PROJECT_ROOT]')
60
+
61
+ filename = filename_for_backtrace_element(element)
62
+
63
+ repositories_for_backtrace.find { |r| r.entry(filename) }
64
+ end
65
+
66
+ def repositories_for_backtrace
67
+ return @_bactrace_repositories unless @_bactrace_repositories.nil?
68
+
69
+ if request.repository.present?
70
+ @_bactrace_repositories = [request.repository]
71
+ else
72
+ @_bactrace_repositories = request.project.repositories.to_a
53
73
  end
54
74
 
55
- nil
75
+ @_bactrace_repositories
56
76
  end
57
77
 
78
+ def filename_for_backtrace_element(element)
79
+ return nil if element[:file].blank?
80
+
81
+ element[:file][14..-1]
82
+ end
58
83
  end
@@ -1,5 +1,6 @@
1
1
  require 'redmine/safe_attributes'
2
2
 
3
+ # Project-specific settings for airbrake
3
4
  class AirbrakeProjectSetting < ActiveRecord::Base
4
5
  include Redmine::SafeAttributes
5
6
 
@@ -0,0 +1,52 @@
1
+ <% if error.present? %>
2
+ h1. <%= error.message %>
3
+
4
+ <% if error.backtrace.present? %>
5
+ h2. Stacktrace:
6
+
7
+ p((. <%=raw error.backtrace.collect { |e| format_backtrace_element(e) }.join("\n") %>
8
+ <% end %>
9
+ <% end %>
10
+
11
+ <% if request.request.present? %>
12
+ h2. Request:
13
+
14
+ <%=format_list_item('URL', request.request[:url]) %>
15
+ <%=format_list_item('Component', request.request[:component]) %>
16
+ <%=format_list_item('Action', request.request[:action]) %>
17
+
18
+ <% if request.request[:params].present? %>
19
+ h2. Parameters:
20
+
21
+ <%=format_table(request.request[:params]) %>
22
+ <% end %>
23
+
24
+ <% if request.request[:cgi_data].present? %>
25
+ h2. Headers:
26
+
27
+ <%=format_table(request.request[:cgi_data]) %>
28
+ <% end %>
29
+
30
+ <% log = request.request[:session].delete(:log) if request.request[:session].present? %>
31
+ <% if request.request[:session].present? %>
32
+ h2. Session:
33
+
34
+ <%=format_table(request.request[:session]) %>
35
+ <% end %>
36
+
37
+ <% if log.present? %>
38
+ h2. Log:
39
+
40
+ <pre>
41
+ <%=format_log(log) %>
42
+ </pre>
43
+ <% end %>
44
+ <% end %>
45
+
46
+ <% if request.env.present? %>
47
+ h2. Environment
48
+
49
+ <%=format_list_item('Name', request.environment_name) %>
50
+ <%=format_list_item('Directory', request.env[:project_root]) %>
51
+ <%=format_list_item('Hostname', request.env[:hostname]) %>
52
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  Rails.application.routes.draw do
2
- post '/notifier_api/v2/notices/' => 'airbrake#notice'
2
+ # notifier api
3
+ post '/notifier_api/v2/notices' => 'airbrake_notice#notice_xml'
4
+ post '/notifier_api/v3/notices' => 'airbrake_notice#notice_json'
5
+ post '/notifier_api/v3/reports/:type' => 'airbrake_report#report'
3
6
 
7
+ # settings
4
8
  resources :projects do
5
9
  member do
6
10
  post 'airbrake_settings' => 'airbrake_project_settings#update'
@@ -1,5 +1,4 @@
1
1
  class CreateAirbrakeProjectSettings < ActiveRecord::Migration
2
-
3
2
  def up
4
3
  create_table :airbrake_project_settings do |t|
5
4
  t.references :project, index: true, null: false
@@ -14,5 +13,4 @@ class CreateAirbrakeProjectSettings < ActiveRecord::Migration
14
13
  def down
15
14
  drop_table :airbrake_project_settings
16
15
  end
17
-
18
16
  end
@@ -1,5 +1,4 @@
1
1
  class AddReopenRepeatDescription < ActiveRecord::Migration
2
-
3
2
  def up
4
3
  add_column :airbrake_project_settings, :reopen_repeat_description, :boolean
5
4
 
@@ -16,5 +15,4 @@ class AddReopenRepeatDescription < ActiveRecord::Migration
16
15
  def down
17
16
  remove_column :airbrake_project_settings, :reopen_repeat_description
18
17
  end
19
-
20
18
  end
@@ -1,9 +1,7 @@
1
1
  require 'redmine_airbrake_backend/engine'
2
2
 
3
3
  module RedmineAirbrakeBackend
4
-
5
4
  def self.directory
6
5
  File.expand_path(File.join(File.dirname(__FILE__), '..'))
7
6
  end
8
-
9
7
  end
@@ -30,20 +30,20 @@ module RedmineAirbrakeBackend
30
30
 
31
31
  def register_redmine_plugin
32
32
  Redmine::Plugin.register :redmine_airbrake_backend do
33
- name 'Airbrake Backend'
34
- author 'Florian Schwab'
35
- author_url 'https://github.com/ydkn'
36
- description 'Airbrake Backend for Redmine'
37
- url 'https://github.com/ydkn/redmine_airbrake_backend'
38
- version ::RedmineAirbrakeBackend::VERSION
39
- requires_redmine :version_or_higher => '2.3.2'
40
- directory RedmineAirbrakeBackend.directory
33
+ name 'Airbrake Backend'
34
+ author 'Florian Schwab'
35
+ author_url 'https://github.com/ydkn'
36
+ description 'Airbrake Backend for Redmine'
37
+ url 'https://github.com/ydkn/redmine_airbrake_backend'
38
+ version ::RedmineAirbrakeBackend::VERSION
39
+ requires_redmine version_or_higher: '2.4.0'
40
+ directory RedmineAirbrakeBackend.directory
41
41
 
42
42
  project_module :airbrake do
43
- permission :manage_airbrake, {airbrake: [:update]}
43
+ permission :manage_airbrake, { airbrake: [:update] }
44
44
  end
45
45
 
46
- settings default: {hash_field: '', occurrences_field: ''}, partial: 'settings/airbrake'
46
+ settings default: { hash_field: '', occurrences_field: '' }, partial: 'settings/airbrake'
47
47
  end
48
48
  end
49
49
  end
@@ -0,0 +1,78 @@
1
+ require 'digest/md5'
2
+
3
+ module RedmineAirbrakeBackend
4
+ # Error received by airbrake
5
+ class Error
6
+ attr_accessor :request
7
+ attr_reader :type, :message, :backtrace
8
+ attr_reader :airbrake_hash, :subject, :attachments
9
+
10
+ def initialize(error_data)
11
+ # Data
12
+ @data = error_data
13
+
14
+ # Type
15
+ @type = @data[:type]
16
+
17
+ # Message
18
+ @message = @data[:message]
19
+
20
+ # Backtrace
21
+ @backtrace = @data[:backtrace]
22
+
23
+ # Attachments
24
+ @attachments = @data[:attachments]
25
+
26
+ # Hash
27
+ @airbrake_hash = generate_hash
28
+
29
+ # Subject
30
+ @subject = generate_subject
31
+ end
32
+
33
+ private
34
+
35
+ def generate_hash
36
+ h = []
37
+ h << filter_hex_values(@type)
38
+ h << filter_hex_values(@message)
39
+ h += normalized_backtrace
40
+
41
+ Digest::MD5.hexdigest(h.compact.join("\n"))
42
+ end
43
+
44
+ def generate_subject
45
+ s = ''
46
+
47
+ if @type.blank? || @message.starts_with?("#{@type}:")
48
+ s = "[#{@airbrake_hash[0..7]}] #{@message}"
49
+ else
50
+ s = "[#{@airbrake_hash[0..7]}] #{@type}: #{@message}"
51
+ end
52
+
53
+ s[0..254].strip
54
+ end
55
+
56
+ def normalized_backtrace
57
+ if @backtrace.present?
58
+ @backtrace.map do |e|
59
+ "#{e[:file]}|#{normalize_method_name(e[:method])}|#{e[:number]}" rescue nil
60
+ end.compact
61
+ else
62
+ []
63
+ end
64
+ end
65
+
66
+ def normalize_method_name(method_name)
67
+ name = e[:method]
68
+ .downcase
69
+ .gsub(/_\d+_/, '') # ruby blocks
70
+
71
+ filter_hex_values(name)
72
+ end
73
+
74
+ def filter_hex_values(value)
75
+ value.gsub(/0x[0-9a-f]+/, '')
76
+ end
77
+ end
78
+ end
@@ -8,17 +8,18 @@ module RedmineAirbrakeBackend::Patches
8
8
  alias_method_chain :project_settings_tabs, :airbrake_backend_tab
9
9
  end
10
10
 
11
+ # add airbrake tab to project settings
11
12
  def project_settings_tabs_with_airbrake_backend_tab
12
13
  tabs = project_settings_tabs_without_airbrake_backend_tab
13
14
 
14
- tabs.push({
15
- :name => 'airbrake',
16
- :action => :manage_airbrake,
17
- :partial => 'projects/settings/airbrake',
18
- :label => :project_module_airbrake
19
- })
15
+ tabs.push(
16
+ name: 'airbrake',
17
+ action: :manage_airbrake,
18
+ partial: 'projects/settings/airbrake',
19
+ label: :project_module_airbrake
20
+ )
20
21
 
21
- tabs.select{|tab| User.current.allowed_to?(tab[:action], @project)}
22
+ tabs.select { |tab| User.current.allowed_to?(tab[:action], @project) }
22
23
  end
23
24
  end
24
25
  end
@@ -0,0 +1,71 @@
1
+ module RedmineAirbrakeBackend
2
+ # Represents a report contained in a request
3
+ class Report
4
+ # iOS report
5
+ class Ios
6
+ # Parse an iOS crash log
7
+ def self.parse(data)
8
+ error = {
9
+ backtrace: [],
10
+ attachments: []
11
+ }
12
+
13
+ header_finished = false
14
+ next_line_is_message = false
15
+ crashed_thread = false
16
+ indicent_identifier = nil
17
+
18
+ data.split("\n").each do |line|
19
+ header_finished = true if line =~ /^(Application Specific Information|Last Exception Backtrace|Thread \d+( Crashed)?):$/
20
+
21
+ unless header_finished
22
+ key, value = line.split(':', 2)
23
+
24
+ next if key.blank? || value.blank?
25
+
26
+ error[:type] = value.strip if key.strip == 'Exception Type'
27
+ error[:message] = value.strip if key.strip == 'Exception Codes'
28
+
29
+ indicent_identifier = value.strip if key.strip == 'Incident Identifier'
30
+ end
31
+
32
+ if next_line_is_message
33
+ next_line_is_message = false
34
+
35
+ error[:message] = line
36
+
37
+ if line =~ /^\*\*\* Terminating app due to uncaught exception '([^']+)', reason: '\*\*\* (.*)'$/
38
+ error[:type] = Regexp.last_match(1)
39
+ error[:message] = Regexp.last_match(2)
40
+ end
41
+ end
42
+
43
+ crashed_thread = false if line =~ /^Thread \d+:$/
44
+
45
+ if crashed_thread
46
+ if line =~ /^(\d+)\s+([^\s]+)\s+(0x[0-9a-f]+)\s+(.+) \+ (\d+)$/
47
+ error[:backtrace] << {
48
+ file: Regexp.last_match(2),
49
+ method: Regexp.last_match(4),
50
+ line: Regexp.last_match(5),
51
+ }
52
+ end
53
+ end
54
+
55
+ crashed_thread = true if error[:backtrace].blank? && line =~ /^(Last Exception Backtrace|Thread \d+ Crashed):$/
56
+
57
+ next_line_is_message = true if line =~ /^Application Specific Information:$/
58
+ end
59
+
60
+ return nil if error.blank?
61
+
62
+ error[:attachments] << {
63
+ filename: "#{indicent_identifier}.crash",
64
+ data: data
65
+ } if indicent_identifier.present?
66
+
67
+ error
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,132 @@
1
+ require 'json'
2
+ require 'base64'
3
+ require 'redmine_airbrake_backend/error'
4
+
5
+ module RedmineAirbrakeBackend
6
+ # Represents a request received by airbrake
7
+ class Request
8
+ class Error < StandardError; end
9
+ class Invalid < Error; end
10
+
11
+ attr_reader :api_key, :project, :tracker, :category, :priority, :assignee, :repository, :type
12
+ attr_reader :params, :notifier, :errors, :context, :request, :env
13
+ attr_reader :environment_name
14
+
15
+ def initialize(config, data = {})
16
+ # config
17
+ @config = self.class.parse_config(config)
18
+ raise Invalid.new('Encoded configuration in api-key is missing') if @config.blank?
19
+
20
+ # API key
21
+ @api_key = @config[:api_key]
22
+ raise Invalid.new('No or invalid api-key') if @api_key.blank?
23
+
24
+ # Project
25
+ @project = Project.where(identifier: @config[:project]).first
26
+ raise Invalid.new('No or invalid project') if @project.blank?
27
+
28
+ # Check configuration
29
+ raise Invalid.new('Custom field for notice hash is not configured!') if notice_hash_field.blank?
30
+
31
+ # Tracker
32
+ @tracker = record_for(@project.trackers, :tracker)
33
+ raise Invalid.new('No or invalid tracker') if @tracker.blank?
34
+ raise Invalid.new('Custom field for notice hash not available on selected tracker') if @tracker.custom_fields.where(id: notice_hash_field.id).first.blank?
35
+
36
+ # Category
37
+ @category = record_for(@project.issue_categories, :category)
38
+
39
+ # Priority
40
+ @priority = record_for(IssuePriority, :priority) || IssuePriority.default
41
+
42
+ # Assignee
43
+ @assignee = record_for(@project.users, :assignee, [:id, :login])
44
+
45
+ # Repository
46
+ @repository = @project.repositories.where(identifier: (@config[:repository] || '')).first
47
+
48
+ # Type
49
+ @type = @config[:type]
50
+
51
+ # Errors
52
+ @errors = (data[:errors] || [])
53
+ @errors.each { |e| e.request = self }
54
+
55
+ # Environment
56
+ @env = data[:env]
57
+
58
+ # Request
59
+ @request = data[:request]
60
+
61
+ # Context
62
+ @context = data[:context]
63
+
64
+ # Notifier
65
+ @notifier = data[:notifier]
66
+
67
+ # Params
68
+ @params = data[:params]
69
+
70
+ # Environment name
71
+ @environment_name = (@env[:environment_name].presence || @env[:name].presence) if @env.present?
72
+ @environment_name ||= @context[:environment].presence if @context.present?
73
+ end
74
+
75
+ def notice_hash_field
76
+ custom_field(:hash_field)
77
+ end
78
+
79
+ def occurrences_field
80
+ custom_field(:occurrences_field)
81
+ end
82
+
83
+ def reopen?
84
+ reopen_regexp = project_setting(:reopen_regexp)
85
+
86
+ return false if environment_name.blank? || reopen_regexp.blank?
87
+
88
+ !!(environment_name =~ /#{reopen_regexp}/i)
89
+ end
90
+
91
+ def reopen_repeat_description?
92
+ !!project_setting(:reopen_repeat_description)
93
+ end
94
+
95
+ private
96
+
97
+ def record_for(on, config_key, fields = [:id, :name])
98
+ fields.each do |field|
99
+ val = on.where(field => @config[config_key]).first
100
+ return val if val.present?
101
+ end
102
+
103
+ project_setting(config_key)
104
+ end
105
+
106
+ def project_setting(key)
107
+ return nil if @project.airbrake_settings.blank?
108
+
109
+ @project.airbrake_settings.send(key) if @project.airbrake_settings.respond_to?(key)
110
+ end
111
+
112
+ def custom_field(key)
113
+ @project.issue_custom_fields.where(id: global_setting(key)).first || CustomField.where(id: global_setting(key), is_for_all: true).first
114
+ end
115
+
116
+ def global_setting(key)
117
+ Setting.plugin_redmine_airbrake_backend[key]
118
+ end
119
+
120
+ def self.parse_config(api_key_data)
121
+ config = ::JSON.parse(api_key_data).symbolize_keys rescue nil
122
+
123
+ return config.symbolize_keys if config.is_a?(Hash)
124
+
125
+ config = ::JSON.parse(Base64.decode64(api_key_data)) rescue nil
126
+
127
+ return config.symbolize_keys if config.is_a?(Hash)
128
+
129
+ nil
130
+ end
131
+ end
132
+ end