Making a Simple Way to Track User Activity with Rails
May 3, 2021 • Ben Sochar
Want to track a User’s journey through your app? Follow up on abandoned transaction?
Still thinking about Reebok Alien Stompers in Red?
We’ve all seen these emails because they’re very effective. You could connect up a bunch 3rd party apps, manaully export data & put into your email client.
For the use case at Ellevate we wanted to use this data to follow up on Users that viewed an Event but didn’t buy a ticket. The latter 1/2 is a lot - let’s focuse on tracking for now.
We’re going to need a new table that contains data about the user & content they viewed so let’s fire up the
1
rails g migration...
class CreateTrackings < ActiveRecord::Migration[5.2]
def change
create_table :trackings do |t|
t.integer :associated_id, null: false
t.integer :user_id, null: false
t.string :action, null: false
t.string :associated_type, null: false
t.timestamps
end
end
end
And our model. We want to validate the User
# app/models/tracking.rb
class Tracking < ApplicationRecord
self.table_name = :trackings
belongs_to :user, class_name: 'User'
belongs_to :associated, polymorphic: true, optional: true
validates :user, presence: true
end
Our routes for the client side tracking. We’ll limit them to the only actions we need & make the response JSON.
# config/routes.rb
resources :trackings, only: [:new, :create], defaults: { format: :json }
We’ll need something for our Javascript to read. I could’ve used url pattern matching to figure out what resource was being viewed… but that would get super messy. Route paths could change. A numerical id might not be in a “pretty url”.
So we’ll use this helper method:
# app/helpers/trackings_helper.rb
module TrackingsHelper
def set_tracking_object(record = nil)
obj = {
action: "#{controller.controller_name} #{controller.action_name}",
}.merge(
associated_type: "#{record.class.name}",
associated_id: "#{record.id}"
) if record
content_for(:tracking_object) { raw(obj.to_json).html_safe }
end
def tracking_json
content_tag :script, content_for(:tracking_object) || raw({ action:"#{controller.controller_name} #{controller.action_name}" }.to_json).html_safe, class: 'js-track-content', type: 'application/json'
end
end
It will output this in a view:
<script class="js-track-content" type="application/json">
{
"action":"events show",
"associated_type":"Events",
"associated_id":"124"
}
</script>
The controller our Javascript will hit. We want to associate the User with the tracking here so we’ll want to double check for one 1st. This also makes sure the User is loaded from the ecrypted cookie.
Note that I entered the Danger Zone by using a reserved word in a controller. But I really wanted it to match Google Analytics so it was easier for the marketing team to understand.
# app/controllers/trackings_controller.rb
class TrackingsController < ApplicationController
before_action :identify_user
def create
@tracking = Tracking::Content.new(tracking_params.merge(user_id: identified_user.id, action: set_action))
if @tracking.save
render status: :ok, json: @tracking
else
render status: :bad_request, json: @tracking.errors.full_messages.join(', ')
end
end
private
def tracking_params
params.require(:tracking).permit(
:associated,
:associated_id,
:associated_type,
:action
)
end
def set_action
# 'action' is reserved word in Rails
request.method == "POST" ? request.request_parameters['action'] : request.query_parameters['action']
end
def identify_user
current_user.present? || cookies[:current_user].present?
end
def identified_user
current_user.presence || User.find(cookies.encrypted[:current_user]) if identify_user
end
end
This is how we track using the client side.
I think it’s wise to pull your routes into your javascript instead of hardcoding to prevent breakage in the future. I’m putting the routes I use into an erb file that will compiled in Webpack. But you hard code it.
// frontend/src/javascripts/routesConfig.js.erb
'use strict'
export default function routesConfig() {
const routes = {
trackings: '<%= Rails.application.routes.url_helpers.trackings_path(format: :json) %>'
}
return routes
}
This uses Axios but any library will work or you can use
1
fetch()
// frontend/src/javascripts/trackContent.js
'use strict'
import ax from 'axiosInit.js'
const Url = require('url-parse')
// Prams can be 'action: my-action'
export default function trackContent (paramsObj = {}) {
// Ignore Admin noise.
const pathsToIgnore = ['admin']
// Check for User first
if (Cookies.get('current_user')) {
const trackingParamsEle = document.querySelector('.js-track-content')
const urlObj = new Url(window.location.href, true)
const routes = routesConfig()
const trackingPath = routes['trackings']
// Check for urls to ignore
const pathsArray = urlObj.pathname.split('/')
const pathIntersections = pathsArray.filter(e => pathsToIgnore.indexOf(e) !== -1)
if (trackingParamsEle && (pathIntersections.length < 1)) {
// Get the tracking data from the HTML element
const trackingParams = JSON.parse(trackingParamsEle.textContent)
ax.post(trackingPath, trackingParams)
.then(function (response) {
})
.catch(function (error) {
// handle error
})
.then(function () {})
}
}
}
document.addEventListener('turbolinks:load', () => {
trackContent()
})
Analytics, Javascript, Rails, & Ruby