Skip to content
Open
2 changes: 1 addition & 1 deletion CHANGELOG.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
* Add normalize_subject option to remove numbers from email so that they thread (by @jjb)
* Allow the user to provide a custom message and hash of data (by @jjb)
* Add support for configurable background sections and a data partial (by @jeffrafter)
* Include timestamp of exception in notification body
* Include timestamp of exception in notification body
* Add support for rack based session management (by @phoet)
* Add ignore_crawlers option to ignore exceptions generated by crawlers
* Add verbode_subject option to exclude exception message from subject (by @amishyn)
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ ExceptionNotification relies on notifiers to deliver notifications when errors o
* [Slack notifier](#slack-notifier)
* [Mattermost notifier](#mattermost-notifier)
* [WebHook notifier](#webhook-notifier)
* [GitHub notifier](#github-notifier)

But, you also can easily implement your own [custom notifier](#custom-notifier).

Expand Down Expand Up @@ -773,6 +774,68 @@ Rails.application.config.middleware.use ExceptionNotification::Rack,

For more HTTParty options, check out the [documentation](https://github.com/jnunemaker/httparty).

### GitHub notifier

This notifier sends notifications, creating issues on GitHub.

#### Usage

Just add the [octokit](https://github.com/github/octokit) gem to your `Gemfile`:

```ruby
gem 'octokit'
```

To configure it, you need to set the `repo`, `login` and `password` options, like this:

```ruby
Rails.application.config.middleware.use ExceptionNotification::Rack,
:email => {
:email_prefix => "[PREFIX] ",
:sender_address => %{"notifier" <[email protected]>},
:exception_recipients => %w{[email protected]}
},
:github => {
:prefix => "[PREFIX] ",
:repo => 'owner/repo',
:login => ENV['GITHUB_LOGIN'],
:password => ENV['GITHUB_PASSWORD']
}
```

#### Options

##### repo

*String, required*

The repo owner and repo name, separated by a forward slash.

##### login

*String, required*

A GitHub username with access rights to the repo

##### password

*String, required*

The username's password.

##### prefix

*String, optional*

A prefix prepended to the issue title.

##### other options

Authentication using OAuth tokens are not (yet) supported.

Assignee, milestone and labels are not (yet) supported.


### Custom notifier

Simply put, notifiers are objects which respond to `#call(exception, options)` method. Thus, a lambda can be used as a notifier as follow:
Expand Down
1 change: 1 addition & 0 deletions exception_notification.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ Gem::Specification.new do |s|
s.add_development_dependency "hipchat", ">= 1.0.0"
s.add_development_dependency "carrier-pigeon", ">= 0.7.0"
s.add_development_dependency "slack-notifier", ">= 1.0.0"
s.add_development_dependency "octokit", ">= 4.3.0"
end
1 change: 1 addition & 0 deletions lib/exception_notifier.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module ExceptionNotifier
autoload :WebhookNotifier, 'exception_notifier/webhook_notifier'
autoload :IrcNotifier, 'exception_notifier/irc_notifier'
autoload :SlackNotifier, 'exception_notifier/slack_notifier'
autoload :GithubNotifier, 'exception_notifier/github_notifier'
autoload :MattermostNotifier, 'exception_notifier/mattermost_notifier'

class UndefinedNotifierError < StandardError; end
Expand Down
148 changes: 148 additions & 0 deletions lib/exception_notifier/github_notifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
require 'action_dispatch'
require 'pp'

module ExceptionNotifier
class GithubNotifier < BaseNotifier
attr_accessor :body, :client, :title, :repo

def initialize(options)
super
begin
@client = Octokit::Client.new(login: options.delete(:login),
password: options.delete(:password))
@repo = options.delete(:repo)
@prefix = options.delete(:prefix) || '[Error] '
end
end

def call(exception, options = {})
@exception = exception
@env = options[:env]
@data = (@env && @env['exception_notifier.exception_data'] || {}).merge(options[:data] || {})
unless @env.nil?
@kontroller = @env['action_controller.instance']
@request = ActionDispatch::Request.new(@env)
@request_hash = hash_from_request
@session = @request.session
@environment = @request.filtered_env
end
@title = compose_title
@body = compose_body
issue_options = { title: @title, body: @body }
send_notice(@exception, options, nil, issue_options) do |_msg, opts|
@client.create_issue(@repo, opts[:title], opts[:body]) if @client.basic_authenticated?
end
end

private

def compose_backtrace_section
return '' if @exception.backtrace.empty?
out = sub_title('Backtrace')
out << "<pre>#{@exception.backtrace.join("\n")}</pre>\n"
end

def compose_body
body = compose_header
if @env.nil?
body << compose_backtrace_section
else
body << compose_request_section
body << compose_session_section
body << compose_environment_section
body << compose_backtrace_section
end
body << compose_data_section
end

def compose_data_section
return '' if @data.empty?
out = sub_title('Data')
out << "<pre>#{PP.pp(@data, '')}</pre>"
end

def compose_environment_section
out = sub_title('Environment')
max = @environment.keys.map(&:to_s).max { |a, b| a.length <=> b.length }
out << "<pre>"
@environment.keys.map(&:to_s).sort.each do |key|
out << "* #{"%-*s: %s" % [max.length, key, inspect_object(@environment[key])]}\n"
end
out << "</pre>"
end

def compose_header
header = @exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A'
header << format(" %s occurred", @exception.class.to_s)
if @kontroller
header << format(" in %s#%s",
@kontroller.controller_name,
@kontroller.action_name)
end
header << format(":\n\n")
header << "<pre>#{@exception.message}\n"
header << "#{@exception.backtrace.first}</pre>"
end

def compose_request_section
return '' if @request_hash.empty?
out = sub_title('Request')
out << "<pre>* URL : #{@request_hash[:url]}\n"
out << "* Referer : #{@request_hash[:referer]}\n"
out << "* HTTP Method: #{@request_hash[:http_method]}\n"
out << "* IP address : #{@request_hash[:ip_address]}\n"
out << "* Parameters : #{@request_hash[:parameters].inspect}\n"
out << "* Timestamp : #{@request_hash[:timestamp]}\n"
out << "* Server : #{Socket.gethostname}\n"
if defined?(Rails) && Rails.respond_to?(:root)
out << "* Rails root : #{Rails.root}\n"
end
out << "* Process : #{$$}</pre>"
end

def compose_session_section
out = sub_title('Session')
id = if @request.ssl?
'[FILTERED]'
else
rack_session_id = (@request.env["rack.session.options"] and @request.env["rack.session.options"][:id])
(@request.session['session_id'] || rack_session_id).inspect
end
out << format("<pre>* session id: %s\n", id)
out << "* data : #{PP.pp(@request.session.to_hash, '')}</pre>"
end

def compose_title
subject = "#{@prefix}"
if @kontroller
subject << "#{@kontroller.controller_name}##{@kontroller.action_name}"
end
subject << " (#{@exception.class.to_s})"
subject.length > 120 ? subject[0...120] + "..." : subject
end

def hash_from_request
{
referer: @request.referer,
http_method: @request.method,
ip_address: @request.remote_ip,
parameters: @request.filtered_parameters,
timestamp: Time.current,
url: @request.original_url
}
end

def inspect_object(object)
case object
when Hash, Array
object.inspect
else
object.to_s
end
end

def sub_title(text)
"\n\n-------------------- #{text} --------------------\n\n"
end
end
end
2 changes: 1 addition & 1 deletion test/dummy/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

ActiveRecord::Schema.define(version: 20110729022608) do

create_table "posts", force: true do |t|
create_table "posts", force: :cascade do |t|
t.string "title"
t.text "body"
t.string "secret"
Expand Down
2 changes: 1 addition & 1 deletion test/exception_notifier/email_notifier_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ class EmailNotifierTest < ActiveSupport::TestCase
ExceptionNotifier::EmailNotifier.normalize_digits('1 foo 12 bar 123 baz 1234')
end

test "mail should be plain text and UTF-8 enconded by default" do
test "mail should be plain text and UTF-8 encoded by default" do
assert_equal @mail.content_type, "text/plain; charset=UTF-8"
end

Expand Down
73 changes: 73 additions & 0 deletions test/exception_notifier/github_notifier_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
require 'test_helper'
require 'octokit'

class GithubNotifierTest < ActiveSupport::TestCase

test "should create github issue if properly configured" do
Octokit::Client.any_instance.expects(:create_issue)

options = {
:prefix => '[Prefix] ',
:repo => 'some/repo',
:login => 'login',
:password => 'password'
}

github = ExceptionNotifier::GithubNotifier.new(options)
github.call(fake_exception,
:env => { "REQUEST_METHOD" => "GET", "rack.input" => "" },
:data => {})
end

test "does not create an authenticated github client if badly configured" do
incomplete_options = {
:prefix => '[Prefix] ',
:repo => 'some/repo',
:login => nil,
:password => 'password'
}

github = ExceptionNotifier::GithubNotifier.new(incomplete_options)
github.call(fake_exception,
:env => { "REQUEST_METHOD" => "GET", "rack.input" => "" },
:data => {})

refute github.client.basic_authenticated?
end

test "github issue is formed with data" do
Octokit::Client.any_instance.expects(:create_issue)

options = {
:prefix => '[Prefix] ',
:repo => 'some/repo',
:login => 'login',
:password => 'password'
}

github = ExceptionNotifier::GithubNotifier.new(options)
github.call(fake_exception,
:env => { "REQUEST_METHOD" => "GET", "rack.input" => "" },
:data => {})

assert_includes github.title, '[Prefix] (ZeroDivisionError)'
assert_includes github.body, 'A ZeroDivisionError occurred:'
assert_includes github.body, 'divided by 0'
assert_includes github.body, '-------------------- Request --------------------'
assert_includes github.body, "* HTTP Method: GET"
assert_includes github.body, "-------------------- Session --------------------"
assert_includes github.body, "* session id: nil"
assert_includes github.body, "-------------------- Environment --------------------"
assert_includes github.body, "* REQUEST_METHOD : GET"
assert_includes github.body, "-------------------- Backtrace --------------------"
assert_includes github.body, "`fake_exception'"
end

private

def fake_exception
5/0
rescue Exception => e
e
end
end