bacchanalytics 0.1.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
1
+ module Bacchanalytics
2
+ class Analytics
3
+ include GoogleAnalytics::Base
4
+ include GoogleAnalytics::TrackingCode
5
+
6
+ def initialize(app, options = {})
7
+ @app = app
8
+ @web_property_id = options[:web_property_id] || "UA-XXXXX-X"
9
+ @domain_name = options[:domain_name]
10
+ @ignored_organic = options[:ignored_organic]
11
+ @skip_ga_src = options[:skip_ga_src] || false
12
+ end
13
+
14
+ def call(env)
15
+ status, headers, response = @app.call(env)
16
+
17
+ if should_instrument?(headers) && (source = response_source(response))
18
+ @skip_ga_src = true if env["bacchanlytics.loaded_ga_src"]
19
+ tracking_code = google_analytics_tracking_code(@web_property_id, @domain_name)
20
+
21
+ env["bacchanalytics.loaded_ga_src"] = true
22
+ new_body = source.sub /<[hH][eE][aA][dD]\s*>/, "<head>\n\n#{tracking_code}"
23
+ headers["Content-Length"] = new_body.length.to_s
24
+ Rack::Response.new(new_body, status, headers).finish
25
+ else
26
+ [status, headers, response]
27
+ end
28
+ end
29
+
30
+ def self.track_page_view_code(page)
31
+ "_gaq.push(['_trackPageview', '#{page}'])"
32
+ end
33
+
34
+ def self.track_page_view_script(page)
35
+ <<-SCRIPT
36
+ <script type="text/javascript">
37
+ #{track_page_view_code(page)};
38
+ </script>
39
+ SCRIPT
40
+ end
41
+
42
+ def self.track_event(category, action, opt_label = nil, opt_value = nil, options = {})
43
+ if opt_label.blank? || opt_value.blank?
44
+ track_event_code = "_gaq.push(['_trackEvent', '#{category}', '#{action}'])"
45
+ else
46
+ track_event_code = "_gaq.push(['_trackEvent', '#{category}', '#{action}', '#{opt_label}', '#{opt_value}'])"
47
+ end
48
+
49
+ timeout = options[:timeout] rescue nil
50
+ if timeout
51
+ "#{track_event_code};setTimeout('void(0)', #{timeout});"
52
+ else
53
+ track_event_code
54
+ end
55
+ end
56
+
57
+ private
58
+ def load_ga_src
59
+ @skip_ga_src ? "" : super
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,74 @@
1
+ module Bacchanalytics
2
+ module GoogleAnalytics
3
+
4
+ module Base
5
+ # Return the javascript code used to load google analytics code
6
+ def load_ga_src
7
+ <<-SCRIPT
8
+ (function() {
9
+ var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
10
+ ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
11
+ var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
12
+ })();
13
+ SCRIPT
14
+ end
15
+
16
+ # headers["Content-Type"] will be nil if the status of the response is 304 (Not Modified)
17
+ # From the HTTP Status Code Definitions:
18
+ # If the client has performed a conditional GET request and access is allowed,
19
+ # but the document has not been modified, the server SHOULD respond with this status code.
20
+ # http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
21
+ def should_instrument?(headers)
22
+ !headers["Content-Type"].nil? && (headers["Content-Type"].include? "text/html")
23
+ end
24
+
25
+ def response_source(response)
26
+ source = nil
27
+ response.each { |fragment| (source) ? (source << fragment) : (source = fragment) }
28
+ source
29
+ end
30
+ end
31
+
32
+ module TrackingCode
33
+ # Construct the new asynchronous version of the Google Analytics code.
34
+ # http://code.google.com/apis/analytics/docs/tracking/asyncTracking.html
35
+ def google_analytics_tracking_code(web_property_id, domain_name = nil)
36
+ <<-SCRIPT
37
+ <script type="text/javascript">
38
+
39
+ var _gaq = _gaq || [];
40
+ _gaq.push(['_setAccount', '#{web_property_id}']);
41
+ if ('#{domain_name}' !== ''){
42
+ _gaq.push(['_setDomainName', '#{domain_name}']);
43
+ }
44
+ #{ignored_organic_script}
45
+ _gaq.push(['_trackPageview']);
46
+
47
+ #{load_ga_src}
48
+ </script>
49
+ SCRIPT
50
+ end
51
+
52
+ def ignored_organic_script
53
+ script = ""
54
+
55
+ begin
56
+ if @ignored_organic.is_a?(Array)
57
+ @ignored_organic.each { |item|
58
+ script << <<-CODE
59
+ _gaq.push(['_addIgnoredOrganic', '#{item}']);
60
+ CODE
61
+ }
62
+ elsif @ignored_organic.is_a?(String)
63
+ script << <<-CODE
64
+ _gaq.push(['_addIgnoredOrganic', '#{@ignored_organic}']);
65
+ CODE
66
+ end
67
+ rescue
68
+ end
69
+
70
+ script
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,3 @@
1
+ module Bacchanalytics
2
+ VERSION = "0.1.9"
3
+ end
@@ -0,0 +1,204 @@
1
+ module Bacchanalytics
2
+
3
+ module WebsiteOptimizerTrackingCode
4
+
5
+ # Construct the Google WebSite Optimizer tracking code.
6
+ def website_optimizer_tracking_code(page, account_id, ab)
7
+ if page.blank? || account_id.blank? || !ab_options_valid?(ab)
8
+ return ""
9
+ end
10
+
11
+ gotc = ""
12
+ begin
13
+ goal_keys = [] # Multiple tests and the same goal
14
+ for key in ab.keys do
15
+ value = ab[key]
16
+ next unless value[:locales].nil? || [value[:locales]].flatten.map(&:to_s).include?(I18n.locale.to_s)
17
+
18
+ a = value[:a].to_a
19
+ b = value[:b].to_a
20
+ g = value[:goal].to_a
21
+
22
+ # Check the requested page, to include the A, B or goal tracking code.
23
+ if a.include?(page)
24
+ gotc = a_tracking_code(account_id, key)
25
+ break
26
+ elsif b.include?(page)
27
+ gotc = b_tracking_code(account_id, key)
28
+ break
29
+ elsif g.include?(page)
30
+ goal_keys << key
31
+ end
32
+ end
33
+
34
+ if gotc.blank?
35
+ gotc = goal_tracking_code(account_id, goal_keys)
36
+ end
37
+
38
+ rescue Exception => e
39
+ logger.debug "#{e.message}"
40
+ gotc = ""
41
+ end
42
+
43
+ gotc
44
+ end
45
+
46
+ private
47
+
48
+ # Returns the A (original page) website optimizer code. Look at the html comments before
49
+ # the javascript code: use them to pass W3C validations.
50
+ def a_tracking_code(account_id, track_page_id)
51
+ result = <<-SCRIPT
52
+ <!-- Google Website Optimizer Control Script -->
53
+ <script type="text/javascript">
54
+ <!--
55
+ function utmx_section(){}function utmx(){}
56
+ (function(){var k='#{track_page_id}',d=document,l=d.location,c=d.cookie;function f(n){
57
+ if(c){var i=c.indexOf(n+'=');if(i>-1){var j=c.indexOf(';',i);return escape(c.substring(i+n.
58
+ length+1,j<0?c.length:j))}}}var x=f('__utmx'),xx=f('__utmxx'),h=l.hash;
59
+ d.write('<sc'+'ript src="'+
60
+ 'http'+(l.protocol=='https:'?'s://ssl':'://www')+'.google-analytics.com'
61
+ +'/siteopt.js?v=1&utmxkey='+k+'&utmx='+(x?x:'')+'&utmxx='+(xx?xx:'')+'&utmxtime='
62
+ +new Date().valueOf()+(h?'&utmxhash='+escape(h.substr(1)):'')+
63
+ '" type="text/javascript" charset="utf-8"></sc'+'ript>')})();
64
+ -->
65
+ </script>
66
+ <script type="text/javascript">utmx("url",'A/B');</script>
67
+ <!-- End of Google Website Optimizer Control Script -->
68
+
69
+ <!-- Google Website Optimizer Tracking Script -->
70
+ <script type="text/javascript">
71
+ var _gaq = _gaq || [];
72
+ _gaq.push(['gwo._setAccount', '#{account_id}']);
73
+ _gaq.push(['gwo._trackPageview', '/#{track_page_id}/test']);
74
+ #{load_ga_src}
75
+ </script>
76
+ <!-- End of Google Website Optimizer Tracking Script -->
77
+ SCRIPT
78
+
79
+ return result
80
+ end
81
+
82
+ # Returns the B (variation page) website optimizer code.
83
+ def b_tracking_code(account_id, track_page_id)
84
+ <<-SCRIPT
85
+ <!-- Google Website Optimizer Tracking Script -->
86
+ <script type="text/javascript">
87
+ var _gaq = _gaq || [];
88
+ _gaq.push(['gwo._setAccount', '#{account_id}']);
89
+ _gaq.push(['gwo._trackPageview', '/#{track_page_id}/test']);
90
+ #{load_ga_src}
91
+ </script>
92
+ <!-- End of Google Website Optimizer Tracking Script -->
93
+ SCRIPT
94
+ end
95
+
96
+ # Returns the goal (conversion page) website optimizer code.
97
+ def goal_tracking_code(account_id, track_page_ids = [])
98
+ track_page_ids = Array(track_page_ids)
99
+
100
+ <<-SCRIPT
101
+ <!-- Google Website Optimizer Tracking Script -->
102
+ <script type="text/javascript">
103
+ var _gaq = _gaq || [];
104
+ _gaq.push(['gwo._setAccount', '#{account_id}']);
105
+ #{track_page_ids.map { |id| "_gaq.push(['gwo._trackPageview', '/#{id}/goal']);" }.join("\n")}
106
+ #{load_ga_src}
107
+ </script>
108
+ <!-- End of Google Website Optimizer Tracking Script -->
109
+ SCRIPT
110
+ end
111
+
112
+ # Are the options of the tests valid?
113
+ def ab_options_valid?(ab_options = {})
114
+ return false if ab_options.empty?
115
+
116
+ unless ab_options_is_complete?(ab_options)
117
+ logger.info "**********************************************************************************************************"
118
+ logger.info "[Bachanalytics] You need to specify the :a, b: and :goal options for your WebSiteOptimizer tests, aborting"
119
+ logger.info "**********************************************************************************************************"
120
+ return false
121
+ end
122
+
123
+ unless uniq_goals?(ab_options)
124
+ logger.info "**************************************************************************************************"
125
+ logger.info "[Bachanalytics] You can't specify a :goal as part of the your WebSiteOptimizer tests, not applying"
126
+ logger.info "**************************************************************************************************"
127
+ return false
128
+ end
129
+
130
+ true
131
+ end
132
+
133
+ # Each A/B test need at least: :a, :b and :goal keys
134
+ def ab_options_is_complete?(ab_options = {})
135
+ result = true
136
+
137
+ ab_options.values.each do |value|
138
+ # Has :a, :b and :goal options
139
+ result = value.keys.size >= 3 && value.key?(:a) && value.key?(:b) && value.key?(:goal)
140
+ break if result == false
141
+ end
142
+
143
+ result
144
+ end
145
+
146
+ # One goal option must not be an a or b option
147
+ # A conversion (goal) option can't ba an a or b option (a original or test option)
148
+ def uniq_goals?(ab_options = {})
149
+ uniq_goals, test_pages = [], []
150
+
151
+ ab_options.values.each do |value|
152
+ uniq_goals << value[:goal]
153
+ test_pages << value[:a]
154
+ test_pages << value[:b]
155
+ end
156
+
157
+ uniq_goals.flatten!.uniq!
158
+ test_pages.flatten!.uniq!
159
+
160
+ (uniq_goals & test_pages).size == 0
161
+ end
162
+ end
163
+
164
+ class WebsiteOptimizer
165
+ include GoogleAnalytics::Base
166
+ include WebsiteOptimizerTrackingCode
167
+
168
+ def initialize(app, options = {})
169
+ @app = app
170
+ @account_id = options[:account_id] || "UA-XXXXX-X"
171
+ @ab = options[:ab] || {}
172
+ @skip_ga_src = options[:skip_ga_src] || false
173
+ end
174
+
175
+ def call(env)
176
+ status, headers, response = @app.call(env)
177
+
178
+ if should_instrument?(headers) && (source = response_source(response))
179
+ page = env['REQUEST_URI']
180
+ page.gsub!(/\?.*/, '') if page #remove url parameters
181
+
182
+ @skip_ga_src = true if env["bacchanalytics.loaded_ga_src"]
183
+ tracking_code = website_optimizer_tracking_code(page, @account_id, @ab)
184
+ return [status, headers, response] if tracking_code.to_s == ""
185
+
186
+ env["bacchanalytics.loaded_ga_src"] = true
187
+ new_body = source.sub /<[hH][eE][aA][dD]\s*>/, "<head>\n\n#{tracking_code}"
188
+ headers["Content-Length"] = new_body.length.to_s
189
+ Rack::Response.new(new_body, status, headers).finish
190
+ else
191
+ [status, headers, response]
192
+ end
193
+ end
194
+
195
+ private
196
+ def logger
197
+ defined?(Rails.logger) ? Rails.logger : Logger.new($stderr)
198
+ end
199
+
200
+ def load_ga_src
201
+ @skip_ga_src ? "" : super
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,64 @@
1
+ require 'test_helper'
2
+ require 'bacchanalytics'
3
+
4
+ ENV['RACK_ENV'] = 'test'
5
+
6
+ class BacchanalyticsIntegrationTest < Test::Unit::TestCase
7
+ include Rack::Test::Methods
8
+ include Bacchanalytics::GoogleAnalytics::Base
9
+
10
+ def app
11
+ response = Rack::Response.new(HTML_DOCUMENT)
12
+ mock_app = lambda do |env|
13
+ [200, {'Content-Type' => 'text/html'}, response]
14
+ end
15
+
16
+ Rack::Builder.new do
17
+ use Bacchanalytics::WebsiteOptimizer, {:account_id => 'UA-20891683-1',
18
+ :ab => {'1924712694' => {:a => ["/"],
19
+ :b => ["/home"],
20
+ :goal => ["/welcome"]}}
21
+ }
22
+ use Bacchanalytics::Analytics, :web_property_id => "UA-12345-6"
23
+ run mock_app
24
+ end
25
+ end
26
+
27
+ def test_must_include_google_analytics_src_only_once
28
+ get "/", {}, {"REQUEST_URI" => "/"}
29
+
30
+ times = 0
31
+ last_response.body.gsub(load_ga_src){|m| times += 1}
32
+
33
+ assert_equal 1, times
34
+ end
35
+
36
+ HTML_DOCUMENT = <<-HTML
37
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
38
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
39
+
40
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
41
+ <head>
42
+ <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
43
+ <title>Listing things</title>
44
+ </head>
45
+ <body>
46
+
47
+ <h1>Listing things</h1>
48
+
49
+ <table>
50
+ <tr>
51
+ <th>Name</th>
52
+ </tr>
53
+ <tr>
54
+ <td>Thing 1</td>
55
+ </tr>
56
+ <tr>
57
+ <td>Thing 2</td>
58
+ </tr>
59
+ </table>
60
+
61
+ </body>
62
+ </html>
63
+ HTML
64
+ end
@@ -0,0 +1,8 @@
1
+ require 'rubygems'
2
+ require 'active_support'
3
+ require 'active_support/test_case'
4
+
5
+ require 'test/unit'
6
+
7
+ require 'rack/test'
8
+ require 'nokogiri'
@@ -0,0 +1,96 @@
1
+ require 'test_helper'
2
+ require 'bacchanalytics'
3
+
4
+ ENV['RACK_ENV'] = 'test'
5
+
6
+ class AdwordsConversionTest < Test::Unit::TestCase
7
+ include Rack::Test::Methods
8
+
9
+ WEB_PROPERTY_ID = "UA-12345-6"
10
+
11
+ def app
12
+ response = Rack::Response.new(HTML_DOCUMENT)
13
+ mock_app = lambda do |env|
14
+ [200, {'Content-Type' => 'text/html'}, response]
15
+ end
16
+ @options ||= [
17
+ {:id => '1062298921', :label=>'oIiDCJe__QEQqcrF-gM', :language=>'es', :format=>3, :value=>3.5, :description=>'',
18
+ :pages=>["/welcome", "/en/welcome", "/es/welcome", "/ca/welcome", "/eu/welcome"]
19
+ }
20
+ ]
21
+ Bacchanalytics::AdwordsConversion.new(mock_app, @options)
22
+ end
23
+
24
+ def test_must_not_include_any_code_if_no_page
25
+ get "/", {}, {'REQUEST_URI' => nil}
26
+
27
+ assert_equal last_response.body, HTML_DOCUMENT
28
+ end
29
+
30
+ def test_must_not_include_any_code_if_the_page_is_not_a_goal
31
+ get "/", {}, {'REQUEST_URI' => nil}
32
+
33
+ assert_equal last_response.body, HTML_DOCUMENT
34
+ end
35
+
36
+ def test_must_include_conversion_code_if_the_page_is_a_goal
37
+ get "/welcome", {}, {'REQUEST_URI' => "/welcome"}
38
+
39
+ assert last_response.body.include?("Google Code for Conversion Page "), last_response.body
40
+ end
41
+
42
+ def test_must_include_conversion_values_in_the_code
43
+ get "/welcome", {}, {'REQUEST_URI' => "/welcome"}
44
+
45
+ conversion = @options.first
46
+ assert last_response.body.include?("google_conversion_id = #{conversion[:id]}"), last_response.body
47
+ assert last_response.body.include?("google_conversion_language = \"#{conversion[:language]}\""), last_response.body
48
+ assert last_response.body.include?("google_conversion_format = \"#{conversion[:format]}\""), last_response.body
49
+ assert last_response.body.include?("google_conversion_label = \"#{conversion[:label]}\""), last_response.body
50
+ assert last_response.body.include?("google_conversion_value = #{conversion[:value]}"), last_response.body
51
+ end
52
+
53
+ def text_must_not_include_any_code_if_not_a_valid_conversion
54
+ invalid_conversion = {}
55
+ @options = [invalid_conversion]
56
+ get "/", {}, {'REQUEST_URI' => "/request_uri"}
57
+
58
+ assert_equal last_response.body, HTML_DOCUMENT
59
+ end
60
+
61
+ def test_must_not_include_any_code_if_no_conversion
62
+ @options = []
63
+ get "/", {}, {'REQUEST_URI' => "/request_uri"}
64
+
65
+ assert_equal last_response.body, HTML_DOCUMENT
66
+ end
67
+
68
+ HTML_DOCUMENT = <<-HTML
69
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
70
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
71
+
72
+ <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
73
+ <head>
74
+ <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
75
+ <title>Listing things</title>
76
+ </head>
77
+ <body>
78
+
79
+ <h1>Listing things</h1>
80
+
81
+ <table>
82
+ <tr>
83
+ <th>Name</th>
84
+ </tr>
85
+ <tr>
86
+ <td>Thing 1</td>
87
+ </tr>
88
+ <tr>
89
+ <td>Thing 2</td>
90
+ </tr>
91
+ </table>
92
+
93
+ </body>
94
+ </html>
95
+ HTML
96
+ end