From 7b3d921cb6d2815b8408f25a4d6704be0719205a Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Sun, 17 Aug 2014 19:46:05 -0700 Subject: [PATCH] Track funnel events with Segment.io * Base approach on http://robots.thoughtbot.com/segment-io-and-ruby. * Add `utm_` campaigns as properties on user creation so cohort analysis can be done on a per-campaign basis later on. * Use special Segment.io `revenue` property and special `campaign` context. https://segment.io/docs/tracking-api/track/ * Ignore Redis backup dump file. https://trello.com/c/gwFd8KCN --- .gitignore | 15 +---- Gemfile | 1 + Gemfile.lock | 7 +++ app/controllers/activations_controller.rb | 1 + app/controllers/application_controller.rb | 13 ++++ app/controllers/deactivations_controller.rb | 1 + app/controllers/sessions_controller.rb | 22 +++++-- app/controllers/subscriptions_controller.rb | 2 + app/models/analytics.rb | 60 +++++++++++++++++++ app/services/build_runner.rb | 8 +++ lib/fake_analytics_ruby.rb | 51 ++++++++++++++++ .../activations_controller_spec.rb | 3 + .../deactivations_controller_spec.rb | 3 + .../subscriptions_controller_spec.rb | 29 +++++++++ spec/features/user_authentication_spec.rb | 41 ++++++++++++- spec/services/build_runner_spec.rb | 3 + spec/spec_helper.rb | 11 ++-- spec/support/helpers/analytics_helper.rb | 5 ++ spec/support/helpers/authentication_helper.rb | 4 +- spec/support/matchers/have_tracked_matcher.rb | 27 +++++++++ 20 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 app/models/analytics.rb create mode 100644 lib/fake_analytics_ruby.rb create mode 100644 spec/support/helpers/analytics_helper.rb create mode 100644 spec/support/matchers/have_tracked_matcher.rb diff --git a/.gitignore b/.gitignore index 556ace3c1..b79493a75 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,6 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. -# -# If you find yourself ignoring temporary files generated by your text editor -# or operating system, you probably want to add a global ignore instead: -# git config --global core.excludesfile ~/.gitignore_global - -# Ignore bundler config +.env /.bundle - -# Ignore the default SQLite database. /db/*.sqlite3 - -# Ignore all logfiles and tempfiles. /log/*.log /tmp - -.env +dump.rdb diff --git a/Gemfile b/Gemfile index 940be35c0..28269fdc3 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" ruby "2.1.2" gem "active_model_serializers" +gem "analytics-ruby" gem "angularjs-rails" gem "bourbon" gem "coffee-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 8baa5a633..b05bd7925 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -28,6 +28,10 @@ GEM thread_safe (~> 0.1) tzinfo (~> 0.3.37) addressable (2.3.6) + analytics-ruby (0.5.4) + faraday (>= 0.8, < 0.10) + faraday_middleware (>= 0.8, < 0.10) + multi_json (~> 1.0) angularjs-rails (1.2.9) arel (4.0.2) ast (2.0.0) @@ -76,6 +80,8 @@ GEM multipart-post (~> 1.2.0) faraday-http-cache (0.4.0) faraday (~> 0.8) + faraday_middleware (0.9.0) + faraday (>= 0.7.4, < 0.9) font-awesome-rails (4.0.3.1) railties (>= 3.2, < 5.0) foreman (0.63.0) @@ -285,6 +291,7 @@ PLATFORMS DEPENDENCIES active_model_serializers + analytics-ruby angularjs-rails bourbon byebug diff --git a/app/controllers/activations_controller.rb b/app/controllers/activations_controller.rb index 0a6099c6c..5d6de5012 100644 --- a/app/controllers/activations_controller.rb +++ b/app/controllers/activations_controller.rb @@ -8,6 +8,7 @@ class CannotActivatePrivateRepo < StandardError; end def create if activator.activate(repo, session[:github_token]) + analytics.track_activated(repo) render json: repo, status: :created else report_exception( diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 143ecfa85..937a14334 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,7 @@ class ApplicationController < ActionController::Base protect_from_forgery before_filter :force_https + before_filter :capture_campaign_params before_filter :authenticate after_filter :set_csrf_cookie_for_ng helper_method :current_user, :signed_in? @@ -19,6 +20,14 @@ def force_https? true end + def capture_campaign_params + session[:campaign_params] ||= { + utm_campaign: params[:utm_campaign], + utm_medium: params[:utm_medium], + utm_source: params[:utm_source], + } + end + def authenticate unless signed_in? redirect_to sign_in_path @@ -33,6 +42,10 @@ def current_user @current_user ||= User.where(remember_token: session[:remember_token]).first end + def analytics + @analytics ||= Analytics.new(current_user, session[:campaign_params]) + end + def set_csrf_cookie_for_ng if protect_against_forgery? cookies['XSRF-TOKEN'] = form_authenticity_token diff --git a/app/controllers/deactivations_controller.rb b/app/controllers/deactivations_controller.rb index 329265b49..dd9ebf05b 100644 --- a/app/controllers/deactivations_controller.rb +++ b/app/controllers/deactivations_controller.rb @@ -7,6 +7,7 @@ def create repo = current_user.repos.find(params[:repo_id]) if activator.deactivate(repo, session[:github_token]) + analytics.track_deactivated(repo) render json: repo, status: :created else report_exception( diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 0d87af3fe..f2f632869 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -5,7 +5,8 @@ def new end def create - create_session + user = find_user || create_user + create_session_for(user) redirect_to root_path end @@ -16,10 +17,23 @@ def destroy private - def create_session - user = User.where(github_username: github_username).first_or_create - session[:github_token] = github_token + def find_user + if user = User.where(github_username: github_username).first + Analytics.new(user).track_signed_in + end + + user + end + + def create_user + user = User.create(github_username: github_username) + Analytics.new(user, session[:campaign_params]).track_signed_up + user + end + + def create_session_for(user) session[:remember_token] = user.remember_token + session[:github_token] = github_token end def destroy_session diff --git a/app/controllers/subscriptions_controller.rb b/app/controllers/subscriptions_controller.rb index 8b6ce3b02..627f84def 100644 --- a/app/controllers/subscriptions_controller.rb +++ b/app/controllers/subscriptions_controller.rb @@ -7,6 +7,7 @@ class FailedToActivate < StandardError; end def create if activator.activate(repo, github_token) && create_subscription + analytics.track_subscribed(repo) render json: repo, status: :created else activator.deactivate(repo, github_token) @@ -19,6 +20,7 @@ def destroy repo = current_user.repos.find(params[:repo_id]) if activator.deactivate(repo, session[:github_token]) && delete_subscription + analytics.track_unsubscribed(repo) render json: repo, status: :created else report_activation_error("Failed to unsubscribe and deactivate repo") diff --git a/app/models/analytics.rb b/app/models/analytics.rb new file mode 100644 index 000000000..4c9d36afe --- /dev/null +++ b/app/models/analytics.rb @@ -0,0 +1,60 @@ +class Analytics + class_attribute :backend + self.backend = AnalyticsRuby + + def initialize(user, params = {}) + @user = user + @params = params + end + + def track_signed_up + track(event: "Signed Up", context: { campaign: campaign_params }) + end + + def track_signed_in + track(event: "Signed In") + end + + def track_activated(repo) + track(event: "Activated Public Repo", properties: repo_properties(repo)) + end + + def track_deactivated(repo) + track(event: "Deactivated Public Repo", properties: repo_properties(repo)) + end + + def track_reviewed(repo) + track(event: "Reviewed Repo", properties: repo_properties(repo)) + end + + def track_subscribed(repo) + track(event: "Subscribed Private Repo", properties: repo_properties(repo)) + end + + def track_unsubscribed(repo) + track(event: "Unsubscribed Private Repo", properties: repo_properties(repo)) + end + + private + + def track(options) + backend.track({ + active_repos_count: user.repos.active.count, + user_id: user.id, + }.merge(options)) + end + + def campaign_params + { + medium: params[:utm_medium], + name: params[:utm_campaign], + source: params[:utm_source], + }.reject { |_, value| value.blank? } + end + + def repo_properties(repo) + { name: repo.full_github_name, revenue: repo.price } + end + + attr_reader :params, :user +end diff --git a/app/services/build_runner.rb b/app/services/build_runner.rb index 5f636c931..b7ec35c77 100644 --- a/app/services/build_runner.rb +++ b/app/services/build_runner.rb @@ -9,6 +9,7 @@ def run if repo && relevant_pull_request? repo.builds.create!(violations: violations) commenter.comment_on_violations(violations, pull_request) + track_reviewed_repo_for_each_user end end @@ -37,4 +38,11 @@ def pull_request def repo @repo ||= Repo.active.where(github_id: payload.github_repo_id).first end + + def track_reviewed_repo_for_each_user + repo.users.each do |user| + analytics = Analytics.new(user) + analytics.track_reviewed(repo) + end + end end diff --git a/lib/fake_analytics_ruby.rb b/lib/fake_analytics_ruby.rb new file mode 100644 index 000000000..1a5462ac8 --- /dev/null +++ b/lib/fake_analytics_ruby.rb @@ -0,0 +1,51 @@ +class FakeAnalyticsRuby + def initialize + @tracked_events = EventsList.new([]) + end + + def track(options) + @tracked_events << options + end + + delegate :tracked_events_for, to: :tracked_events + + private + + attr_reader :tracked_events + + class EventsList + def initialize(events) + @events = events + end + + def <<(event) + @events << event + end + + def tracked_events_for(user) + self.class.new( + events.select do |event| + event[:user_id] == user.id + end + ) + end + + def named(event_name) + self.class.new( + events.select do |event| + event[:event] == event_name + end + ) + end + + def has_keys?(options) + events.any? do |event| + (options.to_a - event.to_a).empty? + end + end + + private + + attr_reader :events + end +end diff --git a/spec/controllers/activations_controller_spec.rb b/spec/controllers/activations_controller_spec.rb index 8ddf4846e..10831b7bf 100644 --- a/spec/controllers/activations_controller_spec.rb +++ b/spec/controllers/activations_controller_spec.rb @@ -15,6 +15,9 @@ expect(response.body).to eq RepoSerializer.new(repo).to_json expect(activator).to have_received(:activate). with(repo, AuthenticationHelper::GITHUB_TOKEN) + expect(analytics).to have_tracked("Activated Public Repo"). + for_user(membership.user). + with(properties: { name: repo.full_github_name, revenue: repo.price }) end end diff --git a/spec/controllers/deactivations_controller_spec.rb b/spec/controllers/deactivations_controller_spec.rb index 45c4908ae..91734b602 100644 --- a/spec/controllers/deactivations_controller_spec.rb +++ b/spec/controllers/deactivations_controller_spec.rb @@ -17,6 +17,9 @@ repo, AuthenticationHelper::GITHUB_TOKEN ) + expect(analytics).to have_tracked("Deactivated Public Repo"). + for_user(membership.user). + with(properties: { name: repo.full_github_name, revenue: repo.price }) end end diff --git a/spec/controllers/subscriptions_controller_spec.rb b/spec/controllers/subscriptions_controller_spec.rb index 32a018350..60958213e 100644 --- a/spec/controllers/subscriptions_controller_spec.rb +++ b/spec/controllers/subscriptions_controller_spec.rb @@ -21,6 +21,9 @@ with(repo, AuthenticationHelper::GITHUB_TOKEN) expect(RepoSubscriber).to have_received(:subscribe). with(repo, membership.user, "cardtoken") + expect(analytics).to have_tracked("Subscribed Private Repo"). + for_user(membership.user). + with(properties: { name: repo.full_github_name, revenue: repo.price }) end it "updates the current user's email address" do @@ -59,3 +62,29 @@ end end end + +describe SubscriptionsController, "#destroy" do + it "unsubscribes the user to the repo" do + membership = create(:membership) + repo = membership.repo + activator = double(:repo_activator, deactivate: true) + RepoActivator.stub(new: activator) + RepoSubscriber.stub(unsubscribe: true) + stub_sign_in(membership.user) + + delete( + :destroy, + repo_id: repo.id, + card_token: "cardtoken", + format: :json + ) + + expect(activator).to have_received(:deactivate). + with(repo, AuthenticationHelper::GITHUB_TOKEN) + expect(RepoSubscriber).to have_received(:unsubscribe). + with(repo, membership.user) + expect(analytics).to have_tracked("Unsubscribed Private Repo"). + for_user(membership.user). + with(properties: { name: repo.full_github_name, revenue: repo.price }) + end +end diff --git a/spec/features/user_authentication_spec.rb b/spec/features/user_authentication_spec.rb index 5f566b162..429f939a0 100644 --- a/spec/features/user_authentication_spec.rb +++ b/spec/features/user_authentication_spec.rb @@ -1,13 +1,52 @@ require 'spec_helper' feature 'User authentication' do - scenario 'user signs in' do + scenario "when user already exists, signs in" do user = create(:user) stub_repo_requests(AuthenticationHelper::GITHUB_TOKEN) sign_in_as(user) expect(page).to have_content user.github_username + expect(analytics).to have_tracked("Signed In").for_user(user) + end + + context "signs up" do + scenario "when user doesn't exist" do + github_username = "croaky" + user = build(:user, github_username: github_username) + stub_repo_requests(AuthenticationHelper::GITHUB_TOKEN) + + sign_in_as(user) + + user = User.where(github_username: github_username).first + expect(page).to have_content(github_username) + expect(analytics).to have_tracked("Signed Up").for_user(user) + end + + scenario "with campaign params" do + github_username = "croaky" + user = build(:user, github_username: github_username) + stub_repo_requests(AuthenticationHelper::GITHUB_TOKEN) + campaign_params = { + utm_campaign: "adwords-ruby", + utm_medium: "paidsearch", + utm_source: "adwords", + } + campaign_context = { + name: "adwords-ruby", + medium: "paidsearch", + source: "adwords", + } + + sign_in_as(user, campaign_params) + + user = User.where(github_username: github_username).first + expect(page).to have_content(github_username) + expect(analytics).to have_tracked("Signed Up"). + for_user(user). + with(context: { campaign: campaign_context }) + end end scenario 'user signs out' do diff --git a/spec/services/build_runner_spec.rb b/spec/services/build_runner_spec.rb index c69ecb218..3f9dd4131 100644 --- a/spec/services/build_runner_spec.rb +++ b/spec/services/build_runner_spec.rb @@ -12,6 +12,9 @@ expect { build_runner.run }.to change { Build.count }.by(1) expect(Build.last).to eq repo.builds.last expect(Build.last.violations).to have_at_least(1).violation + expect(analytics).to have_tracked("Reviewed Repo"). + for_user(repo.users.first). + with(properties: { name: repo.full_github_name, revenue: repo.price }) end it 'comments on violations' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index eb8a64fc0..faf1bef9c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,7 +5,14 @@ require 'rspec/rails' RSpec.configure do |config| + Analytics.backend = FakeAnalyticsRuby.new + + config.before do + DatabaseCleaner.clean + end + config.infer_base_class_for_anonymous_controllers = false + config.include AnalyticsHelper config.include AuthenticationHelper config.include Features, type: :feature config.include HttpsHelper @@ -13,10 +20,6 @@ config.include FactoryGirl::Syntax::Methods DatabaseCleaner.strategy = :deletion Resque.inline = true - - config.before do - DatabaseCleaner.clean - end end Capybara.configure do |config| diff --git a/spec/support/helpers/analytics_helper.rb b/spec/support/helpers/analytics_helper.rb new file mode 100644 index 000000000..e6df892b4 --- /dev/null +++ b/spec/support/helpers/analytics_helper.rb @@ -0,0 +1,5 @@ +module AnalyticsHelper + def analytics + Analytics.backend + end +end diff --git a/spec/support/helpers/authentication_helper.rb b/spec/support/helpers/authentication_helper.rb index e41ad9b82..5d2c4f9b8 100644 --- a/spec/support/helpers/authentication_helper.rb +++ b/spec/support/helpers/authentication_helper.rb @@ -6,9 +6,9 @@ def stub_sign_in(user = create(:user)) session[:github_token] = GITHUB_TOKEN end - def sign_in_as(user) + def sign_in_as(user, params = {}) stub_oauth(user.github_username, GITHUB_TOKEN) - visit root_path + visit root_path(params) click_link(I18n.t('authenticate'), match: :first) end end diff --git a/spec/support/matchers/have_tracked_matcher.rb b/spec/support/matchers/have_tracked_matcher.rb new file mode 100644 index 000000000..9530c9b2f --- /dev/null +++ b/spec/support/matchers/have_tracked_matcher.rb @@ -0,0 +1,27 @@ +RSpec::Matchers.define :have_tracked do |event_name| + match do |backend| + @event_name = event_name + @backend = backend + backend. + tracked_events_for(@user). + named(@event_name). + has_keys?(@keys) + end + + description do + "tracked event" + end + + failure_message_for_should do |_| + "expected event '#{@event_name}' to be tracked for user '#{@user}' " + + "with included keys #{@keys} but was not" + end + + failure_message_for_should_not do |_| + "expected event '#{@event_name}' not to be tracked for user '#{@user}' " + + "with included keys #{@keys} but was" + end + + chain(:for_user) { |user| @user = user } + chain(:with) { |keys| @keys = keys } +end