~/writing/ruby-on-rails

Building a Bulletproof Analytics System for Rails 8 and Turbo

6 min read

Most analytics implementations in Rails apps are fragile. They break when you add Turbo. They fire twice on redirects. They miss events entirely when users navigate via Turbo Drive. I spent a week building a system that actually works, and I want to share it.

The Problem

Here's what typically goes wrong with analytics in modern Rails apps:

  1. Turbo Drive breaks traditional tracking. Page loads don't trigger window.onload anymore. Your carefully placed tracking scripts never fire.
  2. Redirects lose context. You want to track an event after a form submission, but you redirect. By the time the next page loads, you've lost the data you needed to track.
  3. Race conditions everywhere. Your custom tracking code loads after the page has already rendered. Events fire before PostHog or GTM is ready.
  4. Duplicate events. User refreshes the page, your purchase event fires again. Now your conversion numbers are garbage.

The standard advice is "just use GTM" or "track server-side." Neither solves the full problem. You need both, and you need them to work together.

The Architecture

The system has three layers:

  1. Controller concern for queuing events during redirects
  2. View helper for rendering queued events
  3. Custom Turbo Stream actions for client-side execution

Here's how they connect:

Controller Layout Browser
│ │ │
│ track_event_on_redirect │ │
│ ───────────────────────► │ │
│ (stores in session) │ │
│ │ │
│ redirect │ │
│ ─────────────────────────►│ │
│ │ │
│ │ render_pending_analytics │
│ │ ───────────────────────────►
│ │ (turbo-stream element) │
│ │ │
│ │ │ Turbo processes stream
│ │ │ ─────────────────────►
│ │ │ posthog.capture()
│ │ │ dataLayer.push()

Layer 1: The Controller Concern

# app/controllers/concerns/analytics_tracking.rb
module AnalyticsTracking
extend ActiveSupport::Concern
def track_event_on_redirect(event, **properties)
session[:analytics_pending_track] = {
event: event.to_s,
properties: properties
}
end
def identify_user_on_redirect(user)
session[:analytics_pending_identify] = {
distinct_id: user.id.to_s,
properties: {
email: user.email,
name: user.name
}
}
end
def reset_analytics_on_redirect
session[:analytics_pending_reset] = true
end
end

Include it in your ApplicationController:

class ApplicationController < ActionController::Base
include AnalyticsTracking
end

Now any controller can queue analytics:

def create
@order = Order.create!(order_params)
track_event_on_redirect(:order_created, order_id: @order.id, total: @order.total)
redirect_to order_path(@order)
end

The event doesn't fire immediately. It waits in the session until the redirect completes.

Layer 2: The View Helper

# app/helpers/analytics_helper.rb
module AnalyticsHelper
def render_pending_analytics
parts = []
if (track = session.delete(:analytics_pending_track))
parts << turbo_stream_action_tag(
:analytics_track,
event: track["event"],
properties: track["properties"].to_json
)
end
if (identify = session.delete(:analytics_pending_identify))
parts << turbo_stream_action_tag(
:analytics_identify,
distinct_id: identify["distinct_id"],
properties: identify["properties"].to_json
)
end
if session.delete(:analytics_pending_reset)
parts << turbo_stream_action_tag(:analytics_reset)
end
safe_join(parts) if parts.any?
end
end

Critical detail: Notice we use string keys (track["event"]) not symbol keys (track[:event]). Rails sessions serialize to JSON, which converts symbols to strings. This is the kind of bug that takes hours to find.

Layer 3: Custom Turbo Stream Actions

// app/javascript/custom_turbo_stream_actions.js
Turbo.StreamActions.analytics_track = function() {
const event = this.getAttribute("event")
const properties = JSON.parse(this.getAttribute("properties") || "{}")
const dedupeKey = this.getAttribute("dedupe_key")
// Prevent duplicate tracking on page refresh
if (dedupeKey) {
const storageKey = `tracked_${dedupeKey}`
if (sessionStorage.getItem(storageKey)) return
sessionStorage.setItem(storageKey, "true")
}
// Fire to all analytics providers
window.posthog?.capture(event, properties)
window.dataLayer?.push({ event, ...properties })
}
Turbo.StreamActions.analytics_identify = function() {
const distinctId = this.getAttribute("distinct_id")
const properties = JSON.parse(this.getAttribute("properties") || "{}")
window.posthog?.identify(distinctId, properties)
}
Turbo.StreamActions.analytics_reset = function() {
window.posthog?.reset()
}

Import this in your application.js:

import "@hotwired/turbo-rails"
import "custom_turbo_stream_actions"

The Layout: Where It All Comes Together

Here's the critical part that took me a day to figure out. Where you place render_pending_analytics matters.

Wrong (breaks silently):

<head>
<%= javascript_importmap_tags %>
<%= render_pending_analytics %> <!-- DON'T DO THIS -->
</head>

Right:

<head>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
<%= render_pending_analytics %> <!-- At the END of body -->
</body>

Why? Race condition.

When <turbo-stream> elements are in the <head>:

  1. Browser parses <head> before JS modules load
  2. Turbo sees the elements and tries to process them
  3. Your custom actions aren't registered yet
  4. Turbo logs "unknown action" and removes the element
  5. Your event never fires

By placing it at the end of <body>, all JavaScript has loaded. Your custom Turbo Stream actions are registered. Everything works.

Tracking During Turbo Stream Responses

Sometimes you don't redirect. You respond with a Turbo Stream. For these cases, include the tracking directly in your response:

def create
@comment = @post.comments.create!(comment_params)
respond_to do |format|
format.turbo_stream do
render turbo_stream: [
turbo_stream.prepend("comments", @comment),
helpers.turbo_stream_action_tag(
:analytics_track,
event: "comment_created",
properties: { post_id: @post.id }.to_json
)
]
end
end
end

This works because Turbo Stream responses are processed after the page has fully loaded. No race condition.

Deduplication for Critical Events

Purchase events should never fire twice. Use the dedupekey:

<%= turbo_stream_action_tag(:analytics_track,
event: "purchase",
dedupe_key: @order.id,
properties: { value: @order.total }.to_json) %>

The JavaScript checks sessionStorage before firing. If it's seen that dedupekey before, it skips the event.

Don't Forget Early Returns

This bug is sneaky. You have a controller action with multiple code paths:

def create
if some_condition?
redirect_to somewhere_else # OOPS: analytics never queued
return
end
track_event_on_redirect(:something_happened)
redirect_to success_path
end

The early return bypasses your tracking. Fix it:

def create
if some_condition?
track_event_on_redirect(:something_else_happened)
redirect_to somewhere_else
return
end
track_event_on_redirect(:something_happened)
redirect_to success_path
end

Audit every early return in controllers that should track events.

Why This Works

  1. Survives Turbo Drive. Events fire via Turbo Stream actions, which work whether the page loaded via full request or Turbo navigation.
  2. Survives redirects. Session storage bridges the gap between the action and the rendered page.
  3. No race conditions. Placing the helper at the end of body ensures JS is loaded.
  4. No duplicate events. Deduplication via sessionStorage handles refreshes.
  5. Works with any provider. PostHog, GTM, Segment, whatever. The Turbo Stream action is the abstraction layer.
  6. Progressive enhancement. If JavaScript fails, you lose client-side tracking but the app still works. Add server-side tracking for critical events as a fallback.

The Complete File List

app/controllers/concerns/analytics_tracking.rb # Queue events
app/helpers/analytics_helper.rb # Render events
app/javascript/custom_turbo_stream_actions.js # Execute events

Three files. That's the whole system.

Final Thoughts

Most Rails analytics implementations are held together with duct tape. Event listeners that break with Turbo. Inline scripts that fire at the wrong time. Server-side tracking that misses client context.

This system is different. It's built around how Turbo actually works. It uses the session for state, Turbo Streams for execution, and custom actions for flexibility. It's simple enough to understand in an afternoon and robust enough to trust in production.

Copy it. Adapt it. Ship it.