feat: add ahoy analytics for event tracking

- Integrate Ahoy gem for tracking user events and visits
- Create models for Ahoy events and visits
- Implement admin interfaces for managing events and visits
- Add background job for cleaning up old analytics data
- Update application controller and other relevant controllers to track specific actions

This commit implements a comprehensive event tracking system that logs user interactions
within the application. Additionally, it includes mechanisms for managing and
cleaning historical visit and event data, ensuring efficient data handling.
This commit is contained in:
songtianlun 2025-01-27 00:43:18 +08:00
parent 5f30e08a6e
commit dd6cd0451d
18 changed files with 409 additions and 1 deletions

View File

@ -50,6 +50,8 @@ gem "kaminari", "~> 1.2"
gem "meta-tags", "~> 2.22" gem "meta-tags", "~> 2.22"
gem "sitemap_generator", "~> 6.3" gem "sitemap_generator", "~> 6.3"
gem "ahoy_matey", "~> 5.2"
# gem "whenever", "~> 1.0" # gem "whenever", "~> 1.0"
gem "ruby-openai", "~> 7.3" gem "ruby-openai", "~> 7.3"
gem "httparty", "~> 0.22.0" gem "httparty", "~> 0.22.0"

View File

@ -84,6 +84,10 @@ GEM
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.7) addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0) public_suffix (>= 2.0.2, < 7.0)
ahoy_matey (5.2.1)
activesupport (>= 6.1)
device_detector (>= 1)
safely_block (>= 0.4)
arbre (1.7.0) arbre (1.7.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
ruby2_keywords (>= 0.0.2) ruby2_keywords (>= 0.0.2)
@ -136,6 +140,7 @@ GEM
debug (1.10.0) debug (1.10.0)
irb (~> 1.10) irb (~> 1.10)
reline (>= 0.3.8) reline (>= 0.3.8)
device_detector (1.1.3)
devise (4.9.4) devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
@ -385,6 +390,7 @@ GEM
rubyzip (2.4.1) rubyzip (2.4.1)
rufus-scheduler (3.9.2) rufus-scheduler (3.9.2)
fugit (~> 1.1, >= 1.11.1) fugit (~> 1.1, >= 1.11.1)
safely_block (0.4.1)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.28.0) selenium-webdriver (4.28.0)
base64 (~> 0.2) base64 (~> 0.2)
@ -485,6 +491,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
activeadmin (~> 3.2) activeadmin (~> 3.2)
ahoy_matey (~> 5.2)
aws-sdk-s3 (~> 1.177) aws-sdk-s3 (~> 1.177)
bootsnap bootsnap
brakeman brakeman

33
app/admin/ahoy_events.rb Normal file
View File

@ -0,0 +1,33 @@
ActiveAdmin.register Ahoy::Event do
# See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
#
# Uncomment all parameters which should be permitted for assignment
#
# permit_params :visit_id, :user_id, :name, :properties, :time
#
# or
#
# permit_params do
# permitted = [:visit_id, :user_id, :name, :properties, :time]
# permitted << :other if params[:action] == 'create' && current_user.admin?
# permitted
# end
menu priority: 101, label: "事件统计"
actions :index
index do
column :id
column :name
column :time
column :properties
column :user_id
end
filter :name
filter :time
filter :properties
end

View File

@ -0,0 +1,34 @@
# app/admin/ahoy_management.rb
ActiveAdmin.register_page "Ahoy Management" do
menu label: "访问数据管理", parent: "系统管理"
content title: "访问数据管理" do
columns do
column do
panel "数据统计" do
attributes_table_for :ahoy do
row("总事件数") { Ahoy::Event.count }
row("总访问数") { Ahoy::Visit.count }
row("最早事件") { Ahoy::Event.minimum(:time)&.strftime("%Y-%m-%d %H:%M:%S") }
row("最早访问") { Ahoy::Visit.minimum(:started_at)&.strftime("%Y-%m-%d %H:%M:%S") }
end
end
end
column do
panel "操作" do
div class: "buttons" do
button_to "立即清理旧数据", admin_ahoy_management_cleanup_path, method: :post,
data: { confirm: "确定要清理3个月前的数据吗" },
class: "button"
end
end
end
end
end
page_action :cleanup, method: :post do
CleanAhoyDataWorker.perform_async
redirect_to admin_ahoy_management_path, notice: "清理任务已加入队列"
end
end

37
app/admin/ahoy_visits.rb Normal file
View File

@ -0,0 +1,37 @@
ActiveAdmin.register Ahoy::Visit do
# See permitted parameters documentation:
# https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters
#
# Uncomment all parameters which should be permitted for assignment
#
# permit_params :visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at
#
# or
#
# permit_params do
# permitted = [:visit_token, :visitor_token, :user_id, :ip, :user_agent, :referrer, :referring_domain, :landing_page, :browser, :os, :device_type, :country, :region, :city, :latitude, :longitude, :utm_source, :utm_medium, :utm_term, :utm_content, :utm_campaign, :app_version, :os_version, :platform, :started_at]
# permitted << :other if params[:action] == 'create' && current_user.admin?
# permitted
# end
menu priority: 100, label: "访问统计"
actions :index
index do
column :id
column :visitor_token
column :ip
column :user_agent
column :started_at
column :city
column :country
column :region
end
filter :started_at
filter :city
filter :country
end

View File

@ -11,6 +11,43 @@ ActiveAdmin.register_page "Dashboard" do
end end
end end
columns do
column do
panel "访问统计" do
para "总访问量: #{Ahoy::Visit.count}"
para "总事件数: #{Ahoy::Event.count}"
para "独立访客数: #{Ahoy::Visit.distinct.count(:visitor_token)}"
end
end
column do
panel "热门城市" do
table_for City.by_popularity.limit(10) do
column("城市") { |city| link_to(city.name, admin_city_path(city)) }
column("访问量") { |city| city.view_count }
end
end
end
column do
panel "热门天气艺术" do
table_for WeatherArt.by_popularity.limit(10) do
column("作品") { |art| link_to(art.to_s, admin_weather_art_path(art)) }
column("访问量") { |art| art.view_count }
end
end
end
end
# 添加一个事件列表面板
panel "最近事件" do
table_for Ahoy::Event.order(time: :desc).limit(10) do
column :time
column :name
column :properties
end
end
# Here is an example of a simple dashboard with columns and panels. # Here is an example of a simple dashboard with columns and panels.
# #
# columns do # columns do

View File

@ -3,6 +3,13 @@ class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern allow_browser versions: :modern
before_action :set_locale before_action :set_locale
after_action :track_action
protected
def track_action
ahoy.track "Viewed Application", request.path_parameters
end
private private

View File

@ -24,6 +24,11 @@ class CitiesController < ApplicationController
def show def show
@city = City.friendly.find(params[:id]) @city = City.friendly.find(params[:id])
ahoy.track "View City", {
city_id: @city.id,
name: @city.name,
event_type: 'city_view'
}
set_meta_tags( set_meta_tags(
title: @city.name, title: @city.name,

View File

@ -2,6 +2,18 @@ class WeatherArtsController < ApplicationController
def show def show
@city = City.friendly.find(params[:city_id]) @city = City.friendly.find(params[:city_id])
@weather_art = @city.weather_arts.friendly.find(params[:slug]) @weather_art = @city.weather_arts.friendly.find(params[:slug])
ahoy.track "View Weather Art", {
weather_art_id: @weather_art.id,
city_id: @weather_art.city_id,
event_type: 'weather_art_view'
}
ahoy.track "View City", {
city_id: @city.id,
name: @city.name,
event_type: 'city_view'
}
set_meta_tags( set_meta_tags(
title: "#{@city.name} Weather Art - #{@weather_art.weather_date.strftime('%B %d, %Y')}", title: "#{@city.name} Weather Art - #{@weather_art.weather_date.strftime('%B %d, %Y')}",
description: "#{@city.name}'s weather visualized through AI art. #{@weather_art.description} at #{@weather_art.temperature}°C.", description: "#{@city.name}'s weather visualized through AI art. #{@weather_art.description} at #{@weather_art.temperature}°C.",

14
app/models/ahoy/event.rb Normal file
View File

@ -0,0 +1,14 @@
class Ahoy::Event < ApplicationRecord
# include Ahoy::QueryMethods
self.table_name = "ahoy_events"
belongs_to :visit
belongs_to :user, optional: true
serialize :properties, coder: JSON
def self.ransackable_attributes(auth_object = nil)
["id", "id_value", "name", "properties", "time", "user_id", "visit_id"]
end
end

10
app/models/ahoy/visit.rb Normal file
View File

@ -0,0 +1,10 @@
class Ahoy::Visit < ApplicationRecord
self.table_name = "ahoy_visits"
has_many :events, class_name: "Ahoy::Event"
belongs_to :user, optional: true
def self.ransackable_attributes(auth_object = nil)
["app_version", "browser", "city", "country", "device_type", "id", "ip", "landing_page", "latitude", "longitude", "os", "os_version", "platform", "referrer", "referring_domain", "region", "started_at", "user_agent", "user_id", "utm_campaign", "utm_content", "utm_medium", "utm_source", "utm_term", "visit_token", "visitor_token"]
end
end

View File

@ -5,6 +5,9 @@ class City < ApplicationRecord
has_many :weather_arts, dependent: :destroy has_many :weather_arts, dependent: :destroy
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :city_id
has_many :events, class_name: "Ahoy::Event", foreign_key: :city_id
delegate :region, to: :country delegate :region, to: :country
validates :name, presence: true validates :name, presence: true
@ -17,6 +20,23 @@ class City < ApplicationRecord
scope :by_country, ->(country_id) { where(country_id: country_id) } scope :by_country, ->(country_id) { where(country_id: country_id) }
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :by_popularity, -> {
if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.city_id') = cities.id
AND json_extract(ahoy_events.properties, '$.event_type') = 'city_view'")
.group("cities.id")
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
.order("visit_count DESC")
else
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties->>'city_id')::integer = cities.id
AND ahoy_events.properties->>'event_type' = 'city_view'")
.group("cities.id")
.select("cities.*, COUNT(ahoy_events.id) as visit_count")
.order("visit_count DESC")
end
}
def to_s def to_s
name name
end end
@ -65,4 +85,12 @@ class City < ApplicationRecord
def latest_weather_art def latest_weather_art
weather_arts.order(weather_date: :desc).first weather_arts.order(weather_date: :desc).first
end end
def view_count
if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'city_view' AND json_extract(properties, '$.city_id') = ?", self.id).count
else
Ahoy::Event.where("properties->>'event_type' = 'city_view' AND (properties->>'city_id')::integer = ?", self.id).count
end
end
end end

View File

@ -5,9 +5,28 @@ class WeatherArt < ApplicationRecord
belongs_to :city belongs_to :city
has_one_attached :image has_one_attached :image
has_many :visits, class_name: "Ahoy::Visit", foreign_key: :weather_art_id
has_many :events, class_name: "Ahoy::Event", foreign_key: :weather_art_id
validates :weather_date, presence: true validates :weather_date, presence: true
validates :city_id, presence: true validates :city_id, presence: true
scope :by_popularity, -> {
if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
joins("LEFT JOIN ahoy_events ON json_extract(ahoy_events.properties, '$.weather_art_id') = weather_arts.id
AND json_extract(ahoy_events.properties, '$.event_type') = 'weather_art_view'")
.group("weather_arts.id")
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
.order("visit_count DESC")
else
joins("LEFT JOIN ahoy_events ON (ahoy_events.properties->>'weather_art_id')::integer = weather_arts.id
AND ahoy_events.properties->>'event_type' = 'weather_art_view'")
.group("weather_arts.id")
.select("weather_arts.*, COUNT(ahoy_events.id) as visit_count")
.order("visit_count DESC")
end
}
def should_generate_new_friendly_id? def should_generate_new_friendly_id?
weather_date_changed? || city_id_changed? || super weather_date_changed? || city_id_changed? || super
end end
@ -23,4 +42,12 @@ class WeatherArt < ApplicationRecord
def self.ransackable_attributes(auth_object = nil) def self.ransackable_attributes(auth_object = nil)
[ "city_id", "cloud", "created_at", "description", "feeling_temp", "humidity", "id", "id_value", "precipitation", "pressure", "prompt", "temperature", "updated_at", "visibility", "weather_date", "wind_scale", "wind_speed" ] [ "city_id", "cloud", "created_at", "description", "feeling_temp", "humidity", "id", "id_value", "precipitation", "pressure", "prompt", "temperature", "updated_at", "visibility", "weather_date", "wind_scale", "wind_speed" ]
end end
def view_count
if ActiveRecord::Base.connection.adapter_name.downcase == 'sqlite'
Ahoy::Event.where("json_extract(properties, '$.event_type') = 'weather_art_view' AND json_extract(properties, '$.weather_art_id') = ?", self.id).count
else
Ahoy::Event.where("properties->>'event_type' = 'weather_art_view' AND (properties->>'weather_art_id')::integer = ?", self.id).count
end
end
end end

View File

@ -0,0 +1,32 @@
# app/workers/clean_ahoy_data_worker.rb
class CleanAhoyDataWorker
include Sidekiq::Worker
sidekiq_options queue: :default, retry: false
def perform
cleanup_old_events
cleanup_old_visits
log_cleanup_results
end
private
def cleanup_old_events
cutoff_date = 3.months.ago
deleted_events_count = Ahoy::Event.where('time < ?', cutoff_date).delete_all
Rails.logger.info "Deleted #{deleted_events_count} old Ahoy events"
end
def cleanup_old_visits
cutoff_date = 3.months.ago
deleted_visits_count = Ahoy::Visit.where('started_at < ?', cutoff_date).delete_all
Rails.logger.info "Deleted #{deleted_visits_count} old Ahoy visits"
end
def log_cleanup_results
Rails.logger.info "Ahoy cleanup completed at #{Time.current}"
Rails.logger.info "Remaining events: #{Ahoy::Event.count}"
Rails.logger.info "Remaining visits: #{Ahoy::Visit.count}"
end
end

View File

@ -0,0 +1,14 @@
class Ahoy::Store < Ahoy::DatabaseStore
end
# set to true for JavaScript tracking
Ahoy.api = true
# set to true for geocoding (and add the geocoder gem to your Gemfile)
# we recommend configuring local geocoding as well
# see https://github.com/ankane/ahoy#geocoding
Ahoy.geocode = false
Ahoy.visit_duration = 30.minutes
Ahoy.server_side_visits = :when_needed
RETENTION_PERIOD = 3.months

View File

@ -10,3 +10,9 @@ refresh_sitemap:
queue: default queue: default
description: "Refresh sitemap daily" description: "Refresh sitemap daily"
enabled: true enabled: true
clean_ahoy_data:
cron: '0 0 * * 0'
class: CleanAhoyDataWorker
queue: default
enabled: true

View File

@ -0,0 +1,61 @@
class CreateAhoyVisitsAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :ahoy_visits do |t|
t.string :visit_token
t.string :visitor_token
# the rest are recommended but optional
# simply remove any you don't want
# user
t.references :user
# standard
t.string :ip
t.text :user_agent
t.text :referrer
t.string :referring_domain
t.text :landing_page
# technology
t.string :browser
t.string :os
t.string :device_type
# location
t.string :country
t.string :region
t.string :city
t.float :latitude
t.float :longitude
# utm parameters
t.string :utm_source
t.string :utm_medium
t.string :utm_term
t.string :utm_content
t.string :utm_campaign
# native apps
t.string :app_version
t.string :os_version
t.string :platform
t.datetime :started_at
end
add_index :ahoy_visits, :visit_token, unique: true
add_index :ahoy_visits, [:visitor_token, :started_at]
create_table :ahoy_events do |t|
t.references :visit
t.references :user
t.string :name
t.text :properties
t.datetime :time
end
add_index :ahoy_events, [:name, :time]
end
end

44
db/schema.rb generated
View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_23_155234) do ActiveRecord::Schema[8.0].define(version: 2025_01_26_155239) do
create_table "active_admin_comments", force: :cascade do |t| create_table "active_admin_comments", force: :cascade do |t|
t.string "namespace" t.string "namespace"
t.text "body" t.text "body"
@ -65,6 +65,48 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_155234) do
t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_admin_users_on_reset_password_token", unique: true
end end
create_table "ahoy_events", force: :cascade do |t|
t.integer "visit_id"
t.integer "user_id"
t.string "name"
t.text "properties"
t.datetime "time"
t.index ["name", "time"], name: "index_ahoy_events_on_name_and_time"
t.index ["user_id"], name: "index_ahoy_events_on_user_id"
t.index ["visit_id"], name: "index_ahoy_events_on_visit_id"
end
create_table "ahoy_visits", force: :cascade do |t|
t.string "visit_token"
t.string "visitor_token"
t.integer "user_id"
t.string "ip"
t.text "user_agent"
t.text "referrer"
t.string "referring_domain"
t.text "landing_page"
t.string "browser"
t.string "os"
t.string "device_type"
t.string "country"
t.string "region"
t.string "city"
t.float "latitude"
t.float "longitude"
t.string "utm_source"
t.string "utm_medium"
t.string "utm_term"
t.string "utm_content"
t.string "utm_campaign"
t.string "app_version"
t.string "os_version"
t.string "platform"
t.datetime "started_at"
t.index ["user_id"], name: "index_ahoy_visits_on_user_id"
t.index ["visit_token"], name: "index_ahoy_visits_on_visit_token", unique: true
t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at"
end
create_table "cities", force: :cascade do |t| create_table "cities", force: :cascade do |t|
t.string "name" t.string "name"
t.float "latitude" t.float "latitude"