redmine_airbrake_backend 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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