radar 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. data/.gitignore +2 -1
  2. data/CHANGELOG.md +21 -0
  3. data/Gemfile +3 -2
  4. data/Gemfile.lock +47 -48
  5. data/README.md +14 -25
  6. data/docs/user_guide.md +155 -30
  7. data/examples/rack/config.ru +1 -1
  8. data/examples/sinatra/README.md +15 -0
  9. data/examples/sinatra/example.rb +18 -0
  10. data/lib/radar.rb +11 -5
  11. data/lib/radar/application.rb +14 -3
  12. data/lib/radar/backtrace.rb +42 -0
  13. data/lib/radar/config.rb +112 -20
  14. data/lib/radar/data_extensions/rack.rb +4 -21
  15. data/lib/radar/data_extensions/rails2.rb +31 -0
  16. data/lib/radar/data_extensions/request_helper.rb +28 -0
  17. data/lib/radar/exception_event.rb +10 -4
  18. data/lib/radar/integration/rails2.rb +31 -0
  19. data/lib/radar/integration/rails2/action_controller_rescue.rb +30 -0
  20. data/lib/radar/integration/rails3.rb +0 -2
  21. data/lib/radar/integration/rails3/railtie.rb +0 -2
  22. data/lib/radar/integration/sinatra.rb +21 -0
  23. data/lib/radar/matchers/local_request_matcher.rb +43 -0
  24. data/lib/radar/reporter/hoptoad_reporter.rb +204 -0
  25. data/lib/radar/reporter/logger_reporter.rb +2 -3
  26. data/lib/radar/version.rb +1 -1
  27. data/radar.gemspec +1 -0
  28. data/test/radar/application_test.rb +39 -18
  29. data/test/radar/backtrace_test.rb +42 -0
  30. data/test/radar/config_test.rb +49 -4
  31. data/test/radar/data_extensions/rails2_test.rb +14 -0
  32. data/test/radar/exception_event_test.rb +9 -0
  33. data/test/radar/integration/rack_test.rb +1 -1
  34. data/test/radar/integration/sinatra_test.rb +13 -0
  35. data/test/radar/matchers/local_request_matcher_test.rb +26 -0
  36. data/test/radar/reporter/hoptoad_reporter_test.rb +20 -0
  37. data/test/radar/reporter/logger_reporter_test.rb +0 -4
  38. metadata +41 -12
@@ -8,13 +8,15 @@ module Radar
8
8
  class ExceptionEvent
9
9
  attr_reader :application
10
10
  attr_reader :exception
11
- attr_reader :occurred_at
11
+ attr_reader :backtrace
12
12
  attr_reader :extra
13
+ attr_reader :occurred_at
13
14
 
14
15
  def initialize(application, exception, extra=nil)
15
16
  @application = application
16
- @exception = exception
17
- @extra = extra || {}
17
+ @exception = exception
18
+ @backtrace = Backtrace.new(exception.backtrace)
19
+ @extra = extra || {}
18
20
  @occurred_at = Time.now
19
21
  end
20
22
 
@@ -25,11 +27,13 @@ module Radar
25
27
  #
26
28
  # @return [Hash]
27
29
  def to_hash
30
+ return @_to_hash_result if @_to_hash_result
31
+
28
32
  result = { :application => application.to_hash,
29
33
  :exception => {
30
34
  :klass => exception.class.to_s,
31
35
  :message => exception.message,
32
- :backtrace => exception.backtrace,
36
+ :backtrace => backtrace,
33
37
  :uniqueness_hash => uniqueness_hash
34
38
  },
35
39
  :occurred_at => occurred_at.to_i
@@ -43,6 +47,8 @@ module Radar
43
47
  result = filter.call(result)
44
48
  end
45
49
 
50
+ # Cache the resulting hash to it is only generated once.
51
+ @_to_hash_result = result
46
52
  result
47
53
  end
48
54
 
@@ -0,0 +1,31 @@
1
+ require 'radar/integration/rails2/action_controller_rescue'
2
+
3
+ module Radar
4
+ module Integration
5
+ # Allows drop-in integration with Rails 2 for Radar. This monkeypatches
6
+ # `ActionController::Base` to capture errors and also enables a data
7
+ # extension to extract the Rails 2 request information into the exception
8
+ # event.
9
+ class Rails2
10
+ @@integrated_apps = []
11
+
12
+ def self.integrate!(app)
13
+ # Only monkeypatch ActionController::Base once
14
+ ActionController::Base.send(:include, ActionControllerRescue) if @@integrated_apps.empty?
15
+
16
+ # Only integrate each application once
17
+ if !@@integrated_apps.include?(app)
18
+ app.data_extensions.use :rails2
19
+ @@integrated_apps << app
20
+ end
21
+ end
22
+
23
+ # Returns all the Radar applications which have been integrated with
24
+ # Rails 2, in the order that they were integrated. This Array is `dup`ed
25
+ # so that no modifications can be made to it.
26
+ def self.integrated_apps
27
+ @@integrated_apps.dup
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ module Radar
2
+ module Integration
3
+ class Rails2
4
+ module ActionControllerRescue
5
+ def self.included(base)
6
+ # Unfortunate alias method chain... solution: upgrade to rails 3 ;)
7
+ base.send(:alias_method, :rescue_action_without_radar, :rescue_action)
8
+ base.send(:alias_method, :rescue_action, :rescue_action_with_radar)
9
+ base.send(:protected, :rescue_action)
10
+ end
11
+
12
+ private
13
+
14
+ def rescue_action_with_radar(exception)
15
+ report_exception_to_radar(exception)
16
+ rescue_action_without_radar(exception)
17
+ end
18
+
19
+ # Report an exception to all Radar applications which chose to integrate
20
+ # with Rails 2.
21
+ def report_exception_to_radar(exception)
22
+ Radar::Integration::Rails2.integrated_apps.each do |app|
23
+ app.report(exception, :rails2_request => request)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
@@ -1,5 +1,3 @@
1
- require "rails"
2
-
3
1
  module Radar
4
2
  module Integration
5
3
  # Allows drop-in integration with Rails 3 for Radar. This
@@ -1,5 +1,3 @@
1
- require "rails"
2
-
3
1
  module Radar
4
2
  # The Radar Railtie allows Radar to integrate with Rails 3 by
5
3
  # adding generators. **This file is only loaded automatically
@@ -0,0 +1,21 @@
1
+ module Radar
2
+ module Integration
3
+ # Allows drop-in integration with Sinatra for Radar. This class
4
+ # should not ever actually be used {Application#integrate}. Instead,
5
+ # use the middlware provided by {Rack::Radar}.
6
+ #
7
+ # Radar::Application.new(:app) do |app|
8
+ # # configure...
9
+ # end
10
+ #
11
+ # class MyApp < Sinatra::Base
12
+ # use Rack::Radar, :application => Radar[:app]
13
+ # end
14
+ #
15
+ class Sinatra
16
+ def self.integrate!(app)
17
+ raise "To enable Sinatra integration, please use `Rack::Radar` middleware instead. View the user guide for more information."
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,43 @@
1
+ module Radar
2
+ module Matchers
3
+ # A matcher which matches exceptions if the request is from
4
+ # a local IP. The IPs which constitute a "local request" are
5
+ # those which match any of the following:
6
+ #
7
+ # [/^127\.0\.0\.\d{1,3}$/, "::1", /^0:0:0:0:0:0:0:1(%.*)?$/]
8
+ #
9
+ # If there is no request information found in the exception event
10
+ # data, then it will not match.
11
+ #
12
+ # This matcher expects the IP to be accessible at `event.to_hash[:request][:remote_ip]`,
13
+ # though this is configurable. Examples of usage are shown below:
14
+ #
15
+ # app.match :local_request
16
+ #
17
+ # With a custom IP field:
18
+ #
19
+ # app.match :local_request, :remote_ip_getter => lamdba { |event| event.to_hash[:remote_ip] }
20
+ #
21
+ class LocalRequestMatcher
22
+ attr_accessor :localhost
23
+ attr_accessor :remote_ip_getter
24
+
25
+ def initialize(opts=nil)
26
+ (opts || {}).each do |k,v|
27
+ send("#{k}=", v)
28
+ end
29
+
30
+ @localhost ||= [/^127\.0\.0\.\d{1,3}$/, "::1", /^0:0:0:0:0:0:0:1(%.*)?$/]
31
+ @remote_ip_getter ||= Proc.new { |event| event.to_hash[:request][:remote_ip] }
32
+ end
33
+
34
+ def matches?(event)
35
+ remote_ip = remote_ip_getter.call(event)
36
+ localhost.any? { |local_ip| local_ip === remote_ip }
37
+ rescue Exception
38
+ # Any exceptions assume that we didn't match.
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,204 @@
1
+ require 'builder'
2
+ require 'net/http'
3
+ require 'net/https'
4
+
5
+ module Radar
6
+ class Reporter
7
+ # Thanks to the Hoptoad Notifier library for the format of the XML and
8
+ # also the `net/http` code. Writing this reporter would have been much
9
+ # more trying if Thoughtbot's code wasn't open.
10
+
11
+ # Reports exceptions to the Hoptoad server (http://hoptoadapp.com). Enabling
12
+ # this in your Radar application is easy:
13
+ #
14
+ # app.reporters.use :hoptoad, :api_key => "hoptoad-api-key"
15
+ #
16
+ # The API key is your project's API key which can be found on the Hoptoad
17
+ # website. There are many additional options which can be set, but the most
18
+ # useful are probably `project_root` and `environment_name`. These will be
19
+ # auto-detected in a Rails application but for all others, its helpful
20
+ # to set them:
21
+ #
22
+ # app.reporters.use :hoptoad do |r|
23
+ # r.api_key = "api-key"
24
+ # r.project_root = File.expand_path("../../", __FILE__)
25
+ # r.environment_name = "development"
26
+ # end
27
+ #
28
+ class HoptoadReporter
29
+ API_VERSION = "2.0"
30
+ NOTICES_URL = "/notifier_api/v2/notices/"
31
+
32
+ # Options which should be set:
33
+ attr_accessor :api_key
34
+ attr_accessor :project_root
35
+ attr_accessor :environment_name
36
+
37
+ # The rest can probably be left alone:
38
+ # HTTP settings
39
+ attr_accessor :host
40
+ attr_accessor :headers
41
+ attr_accessor :secure
42
+ attr_accessor :http_open_timeout
43
+ attr_accessor :http_read_timeout
44
+
45
+ # Proxy settings
46
+ attr_accessor :proxy_host
47
+ attr_accessor :proxy_port
48
+ attr_accessor :proxy_user
49
+ attr_accessor :proxy_pass
50
+
51
+ # Notifier information. This defaults to Radar information.
52
+ attr_accessor :notifier_name
53
+ attr_accessor :notifier_version
54
+ attr_accessor :notifier_url
55
+
56
+ def initialize(opts=nil)
57
+ (opts || {}).each do |k,v|
58
+ send("#{k}=", v)
59
+ end
60
+
61
+ @project_root ||= defined?(Rails) ? Rails.root.to_s : '/not/set'
62
+ @environment_name ||= defined?(Rails) ? Rails.env.to_s : 'radar'
63
+
64
+ @host ||= 'hoptoadapp.com'
65
+ @headers ||= { 'Content-type' => 'text/xml', 'Accept' => 'text/xml, application/xml' }
66
+ @secure ||= false
67
+ @http_open_timeout ||= 2
68
+ @http_read_timeout ||= 5
69
+
70
+ @notifier_name ||= "Radar"
71
+ @notifier_version ||= Radar::VERSION
72
+ @notifier_url ||= "http://radargem.com"
73
+ end
74
+
75
+ def report(event)
76
+ raise ArgumentError.new("`api_key` is required.") if !api_key
77
+
78
+ http = Net::HTTP.Proxy(proxy_host, proxy_port, proxy_user, proxy_pass).new(url.host, url.port)
79
+ http.read_timeout = http_read_timeout
80
+ http.open_timeout = http_open_timeout
81
+ http.use_ssl = secure
82
+
83
+ response = begin
84
+ http.post(url.path, event_xml(event), headers)
85
+ rescue TimeoutError => e
86
+ event.application.logger.error("#{self.class}: POST timeout.")
87
+ nil
88
+ end
89
+
90
+ if !response.is_a?(Net::HTTPSuccess)
91
+ event.application.logger.error("#{self.class}: Failed to send: #{response.body}")
92
+ end
93
+ end
94
+
95
+ # Converts an event to the properly formatted XML for transmission
96
+ # to Hoptoad.
97
+ def event_xml(event)
98
+ request_data = request_info(event)
99
+
100
+ builder = Builder::XmlMarkup.new
101
+ builder.instruct!
102
+ xml = builder.notice(:version => API_VERSION) do |notice|
103
+ notice.tag!("api-key", api_key)
104
+
105
+ notice.notifier do |notifier|
106
+ notifier.name(notifier_name)
107
+ notifier.version(notifier_version)
108
+ notifier.url(notifier_url)
109
+ end
110
+
111
+ notice.error do |error|
112
+ error.tag!("class", event.exception.class.to_s)
113
+ error.message(event.exception.message)
114
+ error.backtrace do |backtrace|
115
+ event.backtrace.each do |entry|
116
+ backtrace.line(:number => entry.line, :file => entry.file, :method => entry.method)
117
+ end
118
+ end
119
+ end
120
+
121
+ if !request_data.empty?
122
+ notice.request do |request|
123
+ request.url(request_data[:url])
124
+ request.component(request_data[:controller])
125
+ request.action(request_data[:action])
126
+
127
+ if !request_data[:parameters].empty?
128
+ request.params do |params|
129
+ xml_vars_for_hash(params, request_data[:parameters])
130
+ end
131
+ end
132
+
133
+ if !request_data[:session].empty?
134
+ request.session do |session|
135
+ xml_vars_for_hash(session, request_data[:session])
136
+ end
137
+ end
138
+
139
+ if !request_data[:cgi_data].empty?
140
+ request.tag!("cgi-data") do |cgi|
141
+ xml_vars_for_hash(cgi, request_data[:cgi_data])
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ notice.tag!("server-environment") do |env|
148
+ env.tag!("project-root", project_root)
149
+ env.tag!("environment-name", environment_name)
150
+ end
151
+ end
152
+
153
+ xml.to_s
154
+ end
155
+
156
+ # Returns a URI object pointed to the proper endpoint for the
157
+ # Hoptoad API.
158
+ def url
159
+ protocol = secure ? 'https' : 'http'
160
+ port = secure ? 443 : 80
161
+
162
+ URI.parse("#{protocol}://#{host}:#{port}").merge(NOTICES_URL)
163
+ end
164
+
165
+ # Returns information about the request based on the event, such
166
+ # as URL, controller, action, parameters, etc.
167
+ def request_info(event)
168
+ return @_request_info if @_request_info
169
+
170
+ # Use the hash of the event so that if any filters deleted data, it is properly
171
+ # removed.
172
+ hash = event.to_hash.dup
173
+ hash[:request] ||= {}
174
+ hash[:request][:rack_env] ||= {}
175
+
176
+ @_request_info = {}
177
+
178
+ if hash[:request]
179
+ @_request_info[:url] = hash[:request][:url]
180
+ @_request_info[:parameters] = hash[:request][:rack_env]['action_dispatch.request.parameters'] ||
181
+ hash[:request][:parameters] ||
182
+ {}
183
+ @_request_info[:controller] = @_request_info[:parameters]['controller']
184
+ @_request_info[:action] = @_request_info[:parameters]['action']
185
+ @_request_info[:cgi_data] = hash[:request][:rack_env] || hash[:request][:headers] || {}
186
+ @_request_info[:session] = hash[:request][:rack_env]['rack.session'] || {}
187
+ end
188
+
189
+ @_request_info
190
+ end
191
+
192
+ # Turns a hash into the proper XML vars
193
+ def xml_vars_for_hash(builder, hash)
194
+ hash.each do |k,v|
195
+ if v.is_a?(Hash)
196
+ builder.var(:key => k.to_s) { |b| xml_vars_for_hash(b, v) }
197
+ else
198
+ builder.var(v.to_s, :key => k.to_s)
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -5,8 +5,8 @@ module Radar
5
5
  # if you wish to integrate Radar into your already existing logging
6
6
  # systems.
7
7
  #
8
- # app.config.reporters.use :logger, :log_object => Logger.new(STDOUT)
9
- # app.config.reporters.use :logger, :log_object => Logger.new(STDOUT), :log_level => :warn
8
+ # app.reporters.use :logger, :log_object => Logger.new(STDOUT)
9
+ # app.reporters.use :logger, :log_object => Logger.new(STDOUT), :log_level => :warn
10
10
  #
11
11
  class LoggerReporter
12
12
  attr_accessor :log_object
@@ -21,7 +21,6 @@ module Radar
21
21
  end
22
22
 
23
23
  def report(event)
24
- raise ArgumentError.new("#{self.class} `log_object` must be set to a valid logger.") if !log_object.is_a?(Logger)
25
24
  raise ArgumentError.new("#{self.class} `log_object` must respond to specified `log_level`.") if !log_object.respond_to?(log_level)
26
25
 
27
26
  log_object.send(log_level, event.to_json)
@@ -1,3 +1,3 @@
1
1
  module Radar
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0" if !defined?(Radar::VERSION)
3
3
  end
@@ -14,6 +14,7 @@ Gem::Specification.new do |s|
14
14
  s.rubyforge_project = "radar"
15
15
 
16
16
  s.add_dependency "json", ">= 1.4.6"
17
+ s.add_dependency "builder", "~> 2.0"
17
18
 
18
19
  s.add_development_dependency "bundler", ">= 1.0.0.rc.5"
19
20
  s.add_development_dependency "shoulda"
@@ -45,7 +45,9 @@ class ApplicationTest < Test::Unit::TestCase
45
45
  setup do
46
46
  @instance.config.reporters.clear
47
47
 
48
- @reporter = Class.new
48
+ @reporter = Class.new do
49
+ def report(event); end
50
+ end
49
51
  end
50
52
 
51
53
  should "be able to configure an application" do
@@ -108,19 +110,39 @@ class ApplicationTest < Test::Unit::TestCase
108
110
  def matches?(event); event.extra[:foo] == :bar; end
109
111
  end
110
112
 
111
- @reporter = Class.new
113
+ @reporter = Class.new do
114
+ def report(event); raise "Reported"; end
115
+ end
116
+
112
117
  @instance.config.reporters.use @reporter
113
118
  @instance.config.match @matcher
114
119
  end
115
120
 
116
121
  should "not report if a matcher is specified and doesn't match" do
117
- @reporter.any_instance.expects(:report).never
118
- @instance.report(Exception.new, :foo => :wrong)
122
+ assert_nothing_raised { @instance.report(Exception.new, :foo => :wrong) }
119
123
  end
120
124
 
121
125
  should "report if a matcher matches" do
122
- @reporter.any_instance.expects(:report).once
123
- @instance.report(Exception.new, :foo => :bar)
126
+ assert_raises(RuntimeError) { @instance.report(Exception.new, :foo => :bar) }
127
+ end
128
+ end
129
+
130
+ context "with a rejecter" do
131
+ setup do
132
+ @rejecter = Class.new do
133
+ def matches?(event); event.extra[:foo] == :bar; end
134
+ end
135
+
136
+ @instance.reject @rejecter
137
+ @instance.reporter { |event| raise "Reported" }
138
+ end
139
+
140
+ should "not report if a rejecter is specified and matches" do
141
+ assert_nothing_raised { @instance.report(Exception.new, :foo => :bar) }
142
+ end
143
+
144
+ should "report if the rejecter doesn't match" do
145
+ assert_raises(RuntimeError) { @instance.report(Exception.new) }
124
146
  end
125
147
  end
126
148
  end
@@ -148,20 +170,19 @@ class ApplicationTest < Test::Unit::TestCase
148
170
  end
149
171
 
150
172
  context "delegation to config" do
151
- should "delegate reporters" do
152
- assert_equal @instance.config.reporters, @instance.reporters
153
- end
154
-
155
- should "delegate data extensions" do
156
- assert_equal @instance.config.data_extensions, @instance.data_extensions
157
- end
158
-
159
- should "delegate matchers" do
160
- assert_equal @instance.config.matchers, @instance.matchers
173
+ # Test delegating of accessors
174
+ [:reporters, :data_extensions, :matchers, :filters, :rejecters].each do |attr|
175
+ should "delegate #{attr}" do
176
+ assert_equal @instance.config.send(attr), @instance.send(attr)
177
+ end
161
178
  end
162
179
 
163
- should "delegate filters" do
164
- assert_equal @instance.config.filters, @instance.filters
180
+ # Test delegating of methods.
181
+ [:reporter, :data_extension, :match, :filter, :reject].each do |method|
182
+ should "delegate `#{method}` method" do
183
+ @instance.config.expects(method).once
184
+ @instance.send(method)
185
+ end
165
186
  end
166
187
  end
167
188