flowsk.com
Rails

Ruby on Rails install guide

A concern that mints the id and enqueues pageviews, model callbacks that confirm identification and purchases. This is how flowsk.com instruments itself.

Aug 4, 2026· 4 min read ·Docs
Quick answer

before_action mints the id · Active Job records the pageview · webhooks confirm the money

Rails has every primitive this needs: a request cycle you own, cookies you set server-side, background jobs for ingestion, and models that already know the email and the payment. This is the stack we dogfood on — flowsk.com runs exactly this.

Server-side pageviews are more accurate than a client tag on Hotwire apps, because every Turbo Drive navigation is still a real GET to your server.

This is the guide we can be most specific about, because it is what runs this website.

1. The identity concern

# app/controllers/concerns/flowsk_trackable.rb
module FlowskTrackable
  extend ActiveSupport::Concern

  COOKIE = :_fsk_id
  UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
  BOT_RE = /bot|crawl|spider|slurp|preview|monitor|headless|curl|wget/i
  PREFETCH = %w[X-Sec-Purpose Sec-Purpose Purpose X-Purpose X-Moz].freeze

  included { before_action :flowsk_track }

  private

  def flowsk_track
    return unless request.get? && request.format.html?
    return if request.user_agent.to_s.match?(BOT_RE)
    return if flowsk_prefetch?

    FlowskPageviewJob.perform_later(
      flowsk_visitor_id, request.original_url, request.referer,
      request.path, Time.current.iso8601
    )
  rescue => e
    Rails.logger.warn("[flowsk] #{e.class}: #{e.message}")   # never break a page
  end

  # Durable, first-party, server-set. Safari's 7-day JS cap does not apply.
  def flowsk_visitor_id
    existing = cookies[COOKIE]
    return existing if existing.present? && existing.match?(UUID_RE)

    SecureRandom.uuid.tap do |id|
      cookies[COOKIE] = { value: id, expires: 2.years, httponly: false, same_site: :lax }
    end
  end

  def flowsk_prefetch?
    request.headers["Turbo-Frame"].present? ||
      PREFETCH.any? { |h| request.headers[h].to_s.match?(/prefetch|preview|prerender/i) }
  end

  def flowsk_identify(email, via:)
    return if email.blank?
    FlowskEventJob.perform_later(
      flowsk_visitor_id,
      { source: "server", category: "identification", name: via, email: email }
    )
  end
end

Include it in ApplicationController. Three things are already handled that a naive implementation gets wrong: bots, Turbo Frame partials, and speculative prefetches. Each of those inflates or corrupts counts in a way that is embarrassing to discover in a meeting.

2. Pageviews off the request cycle

# app/jobs/flowsk_pageview_job.rb
class FlowskPageviewJob < ApplicationJob
  queue_as :default

  def perform(anonymous_id, url, referrer, path, occurred_at)
    Flowsk.post(
      anonymous_id: anonymous_id,
      events: [{ source: "server", category: "navigation", name: "$view",
                 url: url, referrer: referrer,
                 properties: { path: path }, occurred_at: occurred_at }]
    )
  end
end

Server-side pageviews are more complete than a client tag on a Hotwire app: every Turbo Drive navigation is still a real GET, so nothing depends on JavaScript running at all.

3. Identification, with the cause recorded

class RegistrationsController < ApplicationController
  def create
    @user = User.create!(user_params)
    # `via:` records WHAT caused the identification — the thing the browser
    # cannot know. It shows up on the person and in the journey.
    flowsk_identify(@user.email, via: "signup_started")
    redirect_to dashboard_path
  end
end

class LeadsController < ApplicationController
  def create
    return head :ok if params[:company].present?     # honeypot
    lead = Lead.capture(email: params[:email], source: params[:source])
    return render_error unless lead.persisted?
    flowsk_identify(params[:email], via: "lead_captured")
  end
end

4. Purchases from the payment webhook

# app/services/billing.rb
def handle_checkout_completed(event)
  session = event.data.object

  Flowsk.post(
    anonymous_id: session.metadata["fsk_id"],
    events: [{
      source: "server",
      category: "purchase",
      name: "purchase",
      email: session.customer_details.email,
      value_cents: session.amount_total,
      dedup_key: session.id            # idempotent across webhook retries
    }]
  )
end

Put the visitor id into metadata[:fsk_id] when you create the Checkout Session. That is the join between the browsing session and the payment.

5. Optional: the snippet for click granularity

<%%= javascript_include_tag "https://flowsk.com/flowsk.js",
      async: true, data: { write_key: Rails.application.credentials.flowsk_write_key } %>

The snippet reads the same _fsk_id cookie your server set, so client and server events share one identity automatically. That is the whole reason the cookie is not HttpOnly.

How we isolate the dogfooding

Worth stealing if you build something similar. In this codebase there is a hard boundary:

  • The productSignals::* models and services, the public /api, the /app UI. Generic, multi-tenant, knows nothing about flowsk.
  • The dogfooding — everything under Signals::Internal::*. It is flowsk using the product, and it may only call the public Signals::Client facade, never the internal services.

The rule: if the code is specific to flowsk (our lead capture, our tools, our billing), it is Internal. If it would serve any customer, it is product.

The payoff is that our own instrumentation exercises exactly the contract a customer’s API call does. When we break the public interface, our own funnel breaks first — which is a far better alarm than a test suite.

Gotchas

API-only controllers have no cookie middleware. Include ActionController::Cookies.

Webhook retries — always set a dedup key. Payment providers retry, and so does your job queue after a deploy.

Fragment caching — the identity is set in a before_action, not in a view, so cached fragments are unaffected. Full-page caching would break it, as everywhere else.

Rate limiting — cap ingestion per visitor. Ours allows 20 pageviews per visitor per minute; beyond that it is a script, not a person.

Frequently asked questions

Do I need the JavaScript snippet at all in Rails?

Not for pageviews — every Turbo navigation hits your server anyway, so server-side recording is both simpler and more complete. Add the snippet if you want click and form-submit granularity.

How do I keep this off the request cycle?

Enqueue an Active Job from the before_action and return immediately. Nothing about ingestion should be synchronous with a page render.

What about API-only controllers?

ActionController::API has no cookie middleware by default. Include ActionController::Cookies where you need the visitor id.

How do I make webhook handling idempotent?

Use the payment intent, checkout session or subscription invoice id as the dedup key. Retried deliveries then merge instead of inserting.

How does flowsk isolate its own dogfooding from the product?

Everything flowsk-specific lives under Signals::Internal and talks only to the public Signals::Client facade — never to the internal services. Our own instrumentation uses the same contract a customer's API call does.

The setup this site runs

flowsk.com is instrumented exactly like this, through the same public facade your API calls would use.

Read how we dogfood it

Stop guessing which ad made the sale.

Flowsk Signals stitches the anonymous click to the email to the purchase — first-party, server-side, de-duplicated. One snippet, $29/mo, and every conversion comes with a receipt you can inspect.

Keep reading