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

> Learn how Property Web Builder uses Ahoy for multi-tenant analytics tracking. Monitor visitor behavior with isolated data and real-time dashboards.

- Repository: [Ed Tee/property_web_builder](https://github.com/etewiah/property_web_builder)
- Tags: deep-dive
- Published: 2026-03-01

---

**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`](https://github.com/etewiah/property_web_builder/blob/main/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:

```ruby
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](https://github.com/etewiah/property_web_builder/blob/master/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:

```ruby
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](https://github.com/etewiah/property_web_builder/blob/master/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`](https://github.com/etewiah/property_web_builder/blob/main/app/models/ahoy/visit.rb), the model establishes tenant scoping and traffic categorization:

```ruby
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](https://github.com/etewiah/property_web_builder/blob/master/app/models/ahoy/visit.rb#L42-L71)

### Ahoy::Event Model and Interaction Tracking

The `Ahoy::Event` model in [`app/models/ahoy/event.rb`](https://github.com/etewiah/property_web_builder/blob/main/app/models/ahoy/event.rb) provides named scopes for common real estate interactions:

```ruby
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](https://github.com/etewiah/property_web_builder/blob/master/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:

```ruby
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](https://github.com/etewiah/property_web_builder/blob/master/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:

```ruby
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:

```ruby
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`](https://github.com/etewiah/property_web_builder/blob/main/config/initializers/ahoy.rb).
- **Traffic analysis** leverages semantic scopes (`from_search`, `from_social`, `direct`) defined in [`app/models/ahoy/visit.rb`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/app/models/ahoy/visit.rb) and [`app/models/ahoy/event.rb`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/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`](https://github.com/etewiah/property_web_builder/blob/main/app/services/pwb/analytics_service.rb).