PostHog's Ruby SDK does what it promises. You call posthog.capture,
pass a distinct ID and an event name, and data arrives in your dashboard. The
getting-started guide takes five minutes. The next six months of living with it
take considerably longer.
The gap between "PostHog works" and "PostHog works well in a Rails app that processes mortgage applications" is where most teams lose time. Not because the SDK is bad — it's fine — but because the integration patterns that feel natural in a Rails controller are almost never the ones you want in production. I have spent more hours than I'd like debugging silent event loss, duplicated captures, and properties that looked right in development but arrived mangled in production because of ActiveRecord serialization quirks.
Here's what survived.
The wrapper you'll wish you'd written on day one
Don't scatter posthog.capture calls across your controllers.
You'll end up with forty different places constructing event properties, half of
them missing the company_id you'll need in three months when someone
asks for per-company funnels. Instead, build a thin service object that owns
the capture interface and enforces a consistent property shape.
class Analytics
class << self
def capture(user:, event:, properties: {})
return if suppress_analytics?
merged = default_properties(user).merge(properties)
client.capture({
distinct_id: user.posthog_distinct_id,
event: event,
properties: merged,
timestamp: Time.current.iso8601
})
rescue StandardError => e
# Log, don't raise. Analytics must never break the request.
Rails.logger.warn("[Analytics] capture failed: #{e.message}")
Sentry.capture_exception(e) if defined?(Sentry)
end
private
def client
@client ||= PostHog::Client.new(
api_key: ENV.fetch("POSTHOG_API_KEY"),
host: ENV.fetch("POSTHOG_HOST", "https://app.posthog.com"),
on_error: ->(status, msg) {
Rails.logger.error("[PostHog] #{status}: #{msg}")
}
)
end
def default_properties(user)
{
company_id: user.company_id,
company_name: user.company&.name,
user_role: user.role,
environment: Rails.env,
app_version: AppVersion.current
}.compact
end
def suppress_analytics?
Rails.env.test? || ENV["DISABLE_ANALYTICS"].present?
end
end
end
Three things to notice. First, the rescue StandardError — analytics
must never, under any circumstances, break a user-facing request. This is
non-negotiable in fintech. A crashed mortgage application because PostHog's
server hiccuped is the kind of incident that ends up in a postmortem with your
CTO asking uncomfortable questions. Second, default_properties
ensures every event carries the context you'll need for segmentation. Third,
the suppress_analytics? check keeps your test suite clean and gives
you a kill switch in production.
Moving captures out of the request cycle
The SDK batches events and flushes them in a background thread, so a single
capture call is fast. But "fast" and "predictable" are different
things. In a high-throughput Rails app, I've seen the SDK's internal queue back up
during traffic spikes, causing flush delays that make your event timestamps
unreliable. The fix is to move event capture into Sidekiq entirely.
class AnalyticsEventJob < ApplicationJob
queue_as :analytics
discard_on ActiveJob::DeserializationError
def perform(user_id:, event:, properties: {}, captured_at:)
user = User.find_by(id: user_id)
return unless user
Analytics.capture(
user: user,
event: event,
properties: properties.merge(
original_timestamp: captured_at
)
)
end
end
Note the captured_at parameter. When you defer event capture to a
background job, the timestamp of the job execution is wrong — what matters is when
the user actually did the thing. Capture Time.current.iso8601 at the
call site and pass it through. This is the kind of detail that doesn't matter until
you're building a funnel analysis and your conversion times are off by 30 seconds
to 5 minutes depending on queue depth.
You'll also want a dedicated :analytics queue in Sidekiq. Analytics
jobs should never compete with transactional work like sending loan documents or
processing payments. If the analytics queue backs up, that's fine — events arrive
late but nothing business-critical breaks.
Set up a Sidekiq process specifically for the analytics queue with lower concurrency (2-3 threads). This prevents analytics work from consuming connections in your database pool. In a mortgage app with complex queries, those connections are precious.
The shell command you'll run more than you expect
When events aren't showing up in PostHog and you're not sure whether the
problem is your code, Sidekiq, or PostHog's ingestion pipeline, this is
the fastest way to verify the SDK is actually sending data. Tail your Rails
logs, filter for the analytics tag, and you'll know in seconds whether
capture is being called at all.
# Tail Rails logs filtered to analytics events
$ tail -f log/development.log | grep "\[Analytics\]"
# Verify PostHog is receiving events (check the API directly)
$ curl -s "https://app.posthog.com/api/event/?token=$POSTHOG_API_KEY" \
-H "Authorization: Bearer $POSTHOG_PERSONAL_API_KEY" | \
jq '.results[:3] | .[].event'
# Check Sidekiq queue depth for analytics
$ rails runner "puts Sidekiq::Queue.new('analytics').size"
The broader lesson is this: treat your analytics integration like infrastructure, not like a feature. It needs error handling, monitoring, and a deployment pattern that fails gracefully. PostHog is a powerful tool — but in a Rails app handling financial transactions, the integration layer between your application and any third-party service is always where the interesting problems live.
Build the wrapper early. Move captures to background jobs. Preserve timestamps. Monitor the queue. That's the whole thing. Everything else is details.