radar 0.3.0 → 0.4.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.
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