GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS

Written by thoughtbot

Segment.io and Ruby

Segment.io is a tool that lets developers identify users and track their activity through an application. Armed with this information, developers can understand usage patterns through Segment.io’s integrations with Mixpanel, Klaviyo, Google Analytics, and many others.

Segment.io provides a Ruby gem, AnalyticsRuby, to make it easy to track custom events in Rails applications. In a project I worked on recently, we chose to encapsulate interaction with AnalyticsRuby within one class. Rather than always passing the user’s id, email, name, city, and state to AnalyticsRuby and conditionally sending the identifier from Google Analytics, our Analytics facade manages it for us.

# app/models/analytics.rb
class Analytics
  class_attribute :backend
  self.backend = AnalyticsRuby

  def initialize(user, client_id = nil)
    @user = user
    @client_id = client_id
  end

  def track_user_creation
    identify
    track(
      {
        user_id: user.id,
        event: 'Create User',
        properties: {
          city_state: user.zip.to_region
        }
      }
    )
  end

  def track_user_sign_in
    identify
    track(
      {
        user_id: user.id,
        event: 'Sign In User'
      }
    )
  end

  private

  def identify
    backend.identify(identify_params)
  end

  attr_reader :user, :client_id

  def identify_params
    {
      user_id: user.id,
      traits: user_traits
    }
  end

  def user_traits
    {
      email: user.email,
      first_name: user.first_name,
      last_name: user.last_name,
      city_state: user.city_state,
    }.reject { |key, value| value.blank? }
  end

  def track(options)
    if client_id.present?
      options.merge!(
        context: {
          'Google Analytics' => {
            clientId: client_id
          }
        }
      )
    end
    backend.track(options)
  end
end

Because the application had very little JavaScript and no client-side events we needed to track, we decided to use the Ruby library. Depending on the goals and types of events, oftentimes using both Ruby and JavaScript Segment.io libraries makes sense.

To use Analytics, we first defined a handful of methods on ApplicationController:

class ApplicationController < ActionController::Base
  include Clearance::Controller

  def current_user
    super || Guest.new
  end

  def analytics
    @analytics ||= Analytics.new(current_user, google_analytics_client_id)
  end

  def google_analytics_client_id
    google_analytics_cookie.gsub(/^GA\d\.\d\./, '')
  end

  def google_analytics_cookie
    cookies['_ga'] || ''
  end
end

With the analytics method available on ApplicationController, we were free to fire any events we wanted:

class UsersController < Clearance::UsersController
  private

  def url_after_create
    analytics.track_user_creation
    super
  end
end

class SessionsController < Clearance::SessionsController
  private

  def url_after_create
    analytics.track_user_sign_in
    super
  end
end

Tracking events for non-GET requests is much easier when using the Ruby library - imagine conditionally rendering JavaScript within your application layout using Rails' flash!

With a test fake, acceptance testing analytics events becomes a breeze:

# spec/features/user_signs_in_spec.rb
require 'spec_helper'

feature 'User signs in' do
  scenario 'successfully' do
    user = create :user, password: 'password'

    visit root_path
    click_on 'Sign in'
    fill_in 'Email', with: user.email
    fill_in 'Password', with: 'password'
    click_button 'Sign in'

    expect(analytics).to have_tracked('Sign In User').for_user(user)
    expect(analytics).to have_identified(user)
  end
end

This spec should look pretty familiar to those who’ve written features with RSpec and Capybara before. After exercising sign in functionality, we verify the analytics backend is tracking events correctly by ensuring we’ve tracked the appropriate event for the right person, as well as properly identified that person.

# spec/support/analytics.rb
RSpec.configure do |config|
  config.around :each do |example|
    cached_backend = Analytics.backend
    example.run
    Analytics.backend = cached_backend
  end
end

module Features
  def analytics
    Analytics.backend
  end
end

# spec/spec_helper.rb
RSpec.configure do |config|
  config.before :each, type: :feature do
    Analytics.backend = FakeAnalyticsRuby.new
  end
end

Here, we configure the analytics and ensure it’s using a fake, FakeAnalyticsRuby, which we define:

# lib/fake_analytics_ruby.rb
class FakeAnalyticsRuby
  def initialize
    @identified_users = []
    @tracked_events = EventsList.new([])
  end

  def identify(user)
    @identified_users << user
  end

  def track(options)
    @tracked_events << options
  end

  delegate :tracked_events_for, to: :tracked_events

  def has_identified?(user, traits = { email: user.email })
    @identified_users.any? do |user_hash|
      user_hash[:user_id] == user.id &&
        traits.all? do |key, value|
          user_hash[:traits][key] == value
        end
    end
  end

  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_properties?(options)
      events.any? do |event|
        (options.to_a - event[:properties].to_a).empty?
      end
    end

    private
    attr_reader :events
  end
end

FakeAnalyticsRuby implements the part of the same interface as AnalyticsRuby (from the Ruby gem), namely #identify and #track, and maintains internal state for our RSpec matchers have_tracked and have_identified.

With the chainable with_properties, we can ensure additional information, like the user’s city and state, are passed along when firing the “Create User” event:

# spec/features/guest_signs_up_spec.rb
require 'spec_helper'

feature 'Guest signs up' do
  scenario 'successfully' do
    complete_registration city: 'Boston', state: 'MA'

    expect(analytics).to have_tracked('Create User').
      for_user(User.last).
      with_properties({
        city_state: 'Boston, MA'
      })
  end
end

To test Analytics at a unit level, we stub and spy:

# spec/models/analytics_spec.rb

describe Analytics do
  describe '#track_user_sign_in' do
    it 'notifies AnalyticsRuby of a user signing in' do
      AnalyticsRuby.stub(:track)
      user = build_stubbed(:user)

      analytics = Analytics.new(user)
      analytics.track_user_sign_in

      expect(AnalyticsRuby).to have_received(:track).with(
        {
          user_id: user.id,
          event: 'Sign In User'
        }
      )
    end
  end
end

In this app, we tracked seven different events and tested each appropriately. This solution worked well for a number of reasons:

  • with an injectible fake, we were able to easily test events were triggered with the correct properties
  • with a facade to simplify interaction with AnalyticsRuby, we were able to layer additional functionality like tracking with Google Analytics with relative ease
  • with two custom RSpec matchers, we were able to add expressive assertions against fired events
  • with no JavaScript event requirements, we were able to use Ruby and avoid patterns like using flash to conditionally render JavaScript triggering Segment.io events

Segment.io is a solid platform upon which we can develop robust event tracking, and with a bit of work, integrating this tracking into a Rails app is a great way to gain insight into your customers.