bacchanalytics 0.1.9

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,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