Analytics Tracking with Ahoy in Property Web Builder: Multi-Tenant Visitor Behavior

Property Web Builder implements multi-tenant analytics tracking with Ahoy by injecting website_id into every visit and event, storing them in isolated database tables, and aggregating metrics through Pwb::AnalyticsService for real-time dashboards and conversion funnels.

The open-source Property Web Builder repository leverages the Ahoy gem to capture detailed visitor behavior across multiple tenant websites. This implementation creates a fully isolated analytics environment where each website owner accesses only their own traffic data, with all components living under config/initializers, app/models/ahoy, and app/services/pwb.

Multi-Tenant Ahoy Store Configuration

The foundation of analytics tracking with Ahoy resides in config/initializers/ahoy.rb, where the default store is subclassed to support multi-tenant architecture.

Injecting Tenant Context into Visits and Events

The custom Ahoy::Store class inherits from Ahoy::DatabaseStore and overrides track_visit and track_event to inject the current tenant identifier:

class Ahoy::Store < Ahoy::DatabaseStore
  def track_visit(data)
    data[:website_id] = Pwb::Current.website&.id
    super(data)
  end

  def track_event(data)
    data[:website_id] = Pwb::Current.website&.id
    super(data)
  end
end

Source: config/initializers/ahoy.rb#L6-L30

Excluding Admin Routes from Tracking

The exclude? method filters out internal traffic, bots, and administrative paths to ensure analytics reflect genuine visitor behavior:

def exclude?
  return true unless Pwb::Current.website.present?
  return true if bot?
  request_path = request.path.to_s
  return true if request_path.start_with?("/site_admin", "/admin", "/rails/")
  false
end

This prevents the store from recording developer activity and back-office operations in the analytics data.

Database Schema for Tenant-Isolated Analytics

A dedicated migration creates two tenant-aware tables linked to pwb_websites via foreign keys:

  • ahoy_visits stores session metadata including visit_token, visitor_token, started_at, UTM parameters, referrer, device type, browser, OS, and geolocation (country, region, city)
  • ahoy_events records discrete interactions with name, properties (JSONB), time, and visit_id

Both tables include website_id to enforce tenant isolation at the database level.

Source: db/migrate/20251216210000_create_ahoy_visits_and_events.rb

Domain Models and Query Scopes

The repository extends Ahoy's default models with domain-specific scopes and associations to simplify analytics queries.

Ahoy::Visit Model and Traffic Source Analysis

Defined in app/models/ahoy/visit.rb, the model establishes tenant scoping and traffic categorization:

module Ahoy
  class Visit < ::ApplicationRecord
    self.table_name = "ahoy_visits"

    belongs_to :website, class_name: "Pwb::Website"
    belongs_to :user,    class_name: "Pwb::User", optional: true
    has_many   :events,  class_name: "Ahoy::Event", dependent: :destroy

    scope :for_website, ->(website) { where(website: website) }
    scope :in_period,    ->(s, e) { where(started_at: s..e) }
    scope :from_search, -> { where(referring_domain: %w[google.com bing.com yahoo.com duckduckgo.com]) }
    scope :from_social, -> { where(referring_domain: %w[facebook.com twitter.com instagram.com linkedin.com]) }
    scope :direct,      -> { where(referring_domain: nil) }
    scope :desktop, -> { where(device_type: "Desktop") }
    scope :mobile,  -> { where(device_type: "Mobile") }
  end
end

These scopes enable granular filtering by traffic source (search engines vs. social media vs. direct) and device type without repetitive SQL.

Source: app/models/ahoy/visit.rb#L42-L71

Ahoy::Event Model and Interaction Tracking

The Ahoy::Event model in app/models/ahoy/event.rb provides named scopes for common real estate interactions:

module Ahoy
  class Event < ::ApplicationRecord
    self.table_name = "ahoy_events"

    belongs_to :visit,   class_name: "Ahoy::Visit", optional: true
    belongs_to :website, class_name: "Pwb::Website"

    scope :for_website, ->(website) { where(website: website) }
    scope :in_period,   ->(s, e) { where(time: s..e) }
    scope :page_views,          -> { by_name("page_viewed") }
    scope :property_views,      -> { by_name("property_viewed") }
    scope :inquiries,           -> { by_name("inquiry_submitted") }
    scope :searches,            -> { by_name("property_searched") }
    scope :contact_form_opens, -> { by_name("contact_form_opened") }
  end
end

These abstractions allow dashboard controllers to query Ahoy::Event.property_views.for_website(current_site) rather than constructing raw SQL.

Source: app/models/ahoy/event.rb#L28-L55

Aggregating Metrics with Pwb::AnalyticsService

The Pwb::AnalyticsService class transforms raw Ahoy records into dashboard-ready data structures. Instantiate it with a website and time period:

analytics = Pwb::AnalyticsService.new(current_website, period: 30.days)

Key aggregation methods include:

  • overview – Returns high-level metrics (total_visits, unique_visitors, total_pageviews)
  • visits_by_day – Time-series hash mapping dates to visit counts
  • top_properties(limit: n) – Aggregates property_viewed events by property_id and enriches with property records
  • traffic_by_source_type – Categorizes visits into direct, search, social, and referral buckets
  • real_time_visitors – Counts unique visitors in the last 30 minutes for live dashboards
  • inquiry_funnel – Calculates step-by-step conversion rates from visits to property views to contact form opens to inquiries

Source: app/services/pwb/analytics_service.rb

Recording and Querying Analytics Data

Manual Event Creation

To record custom interactions programmatically, create visit and event records directly:

visit = Ahoy::Visit.create!(
  website: current_website,
  visit_token: SecureRandom.uuid,
  visitor_token: SecureRandom.uuid,
  started_at: Time.current,
  referrer: request.referer,
  device_type: browser.device.mobile? ? 'Mobile' : 'Desktop',
  browser: browser.name,
  os: browser.platform.name
)

Ahoy::Event.create!(
  website: current_website,
  visit: visit,
  name: 'property_viewed',
  properties: { property_id: property.id, price: property.price_current },
  time: Time.current
)

Dashboard Controller Implementation

Controllers consume the service to feed JSON APIs or server-rendered views:

class Pwb::AnalyticsController < ApplicationController
  def dashboard
    @analytics = Pwb::AnalyticsService.new(Pwb::Current.website, period: 30.days)
    render json: {
      overview: @analytics.overview,
      visits_by_day: @analytics.visits_by_day,
      top_properties: @analytics.top_properties,
      real_time_visitors: @analytics.real_time_visitors
    }
  end
end

Summary

  • Tenant isolation is enforced by injecting website_id into every visit and event via the custom Ahoy::Store class in config/initializers/ahoy.rb.
  • Traffic analysis leverages semantic scopes (from_search, from_social, direct) defined in app/models/ahoy/visit.rb to categorize visitors without complex SQL.
  • Interaction tracking uses named event scopes (property_views, inquiries) in app/models/ahoy/event.rb to monitor the conversion funnel.
  • Data aggregation is handled by Pwb::AnalyticsService, which provides time-series charts, top-N reports, and real-time counters for dashboard visualization.
  • Test coverage is supported by factories in spec/factories/ahoy.rb that generate realistic multi-tenant analytics data.

Frequently Asked Questions

How does Property Web Builder ensure analytics data remains isolated between tenant websites?

According to the source code in config/initializers/ahoy.rb, the system subclasses Ahoy::DatabaseStore to create a custom store that injects Pwb::Current.website&.id into every visit and event record. The domain models in app/models/ahoy/visit.rb and app/models/ahoy/event.rb then provide for_website scopes that filter all queries by this website_id foreign key, ensuring tenants cannot access each other's analytics data.

What specific visitor interactions does the Ahoy implementation track by default?

As defined in app/models/ahoy/event.rb, the system tracks page_viewed, property_viewed, inquiry_submitted, property_searched, and contact_form_opened events. These cover the core real estate funnel from general browsing to specific property interest and lead generation.

How can I prevent internal admin activity from polluting analytics reports?

The Ahoy::Store#exclude? method in config/initializers/ahoy.rb automatically returns true (skipping tracking) for requests where the path starts with /site_admin, /admin, or /rails/, or when the request comes from a bot or lacks a valid website context. This filtering happens at the middleware level before records reach the database.

What methods does Pwb::AnalyticsService provide for real-time monitoring?

The service offers real_time_visitors and real_time_page_views methods that query records from the last 30 minutes, enabling live dashboard widgets without requiring additional infrastructure like WebSockets or background jobs. These methods are implemented in app/services/pwb/analytics_service.rb.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →