From dd6cd0451d9b93bf9e3b833da051339d852e062b Mon Sep 17 00:00:00 2001 From: songtianlun Date: Mon, 27 Jan 2025 00:43:18 +0800 Subject: [PATCH] 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. --- Gemfile | 2 + Gemfile.lock | 7 +++ app/admin/ahoy_events.rb | 33 ++++++++++ app/admin/ahoy_management.rb | 34 +++++++++++ app/admin/ahoy_visits.rb | 37 +++++++++++ app/admin/dashboard.rb | 37 +++++++++++ app/controllers/application_controller.rb | 7 +++ app/controllers/cities_controller.rb | 5 ++ app/controllers/weather_arts_controller.rb | 12 ++++ app/models/ahoy/event.rb | 14 +++++ app/models/ahoy/visit.rb | 10 +++ app/models/city.rb | 28 +++++++++ app/models/weather_art.rb | 27 ++++++++ app/workers/clean_ahoy_data_worker.rb | 32 ++++++++++ config/initializers/ahoy.rb | 14 +++++ config/sidekiq_scheduler.yml | 6 ++ ...126155239_create_ahoy_visits_and_events.rb | 61 +++++++++++++++++++ db/schema.rb | 44 ++++++++++++- 18 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 app/admin/ahoy_events.rb create mode 100644 app/admin/ahoy_management.rb create mode 100644 app/admin/ahoy_visits.rb create mode 100644 app/models/ahoy/event.rb create mode 100644 app/models/ahoy/visit.rb create mode 100644 app/workers/clean_ahoy_data_worker.rb create mode 100644 config/initializers/ahoy.rb create mode 100644 db/migrate/20250126155239_create_ahoy_visits_and_events.rb diff --git a/Gemfile b/Gemfile index 864954d..1d6c1bc 100644 --- a/Gemfile +++ b/Gemfile @@ -50,6 +50,8 @@ gem "kaminari", "~> 1.2" gem "meta-tags", "~> 2.22" gem "sitemap_generator", "~> 6.3" +gem "ahoy_matey", "~> 5.2" + # gem "whenever", "~> 1.0" gem "ruby-openai", "~> 7.3" gem "httparty", "~> 0.22.0" diff --git a/Gemfile.lock b/Gemfile.lock index a22c78e..30d3377 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -84,6 +84,10 @@ GEM uri (>= 0.13.1) addressable (2.8.7) 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) activesupport (>= 3.0.0) ruby2_keywords (>= 0.0.2) @@ -136,6 +140,7 @@ GEM debug (1.10.0) irb (~> 1.10) reline (>= 0.3.8) + device_detector (1.1.3) devise (4.9.4) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -385,6 +390,7 @@ GEM rubyzip (2.4.1) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) + safely_block (0.4.1) securerandom (0.4.1) selenium-webdriver (4.28.0) base64 (~> 0.2) @@ -485,6 +491,7 @@ PLATFORMS DEPENDENCIES activeadmin (~> 3.2) + ahoy_matey (~> 5.2) aws-sdk-s3 (~> 1.177) bootsnap brakeman diff --git a/app/admin/ahoy_events.rb b/app/admin/ahoy_events.rb new file mode 100644 index 0000000..f188a63 --- /dev/null +++ b/app/admin/ahoy_events.rb @@ -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 diff --git a/app/admin/ahoy_management.rb b/app/admin/ahoy_management.rb new file mode 100644 index 0000000..0cb224e --- /dev/null +++ b/app/admin/ahoy_management.rb @@ -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 \ No newline at end of file diff --git a/app/admin/ahoy_visits.rb b/app/admin/ahoy_visits.rb new file mode 100644 index 0000000..cad11d2 --- /dev/null +++ b/app/admin/ahoy_visits.rb @@ -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 diff --git a/app/admin/dashboard.rb b/app/admin/dashboard.rb index d743303..7e8284e 100644 --- a/app/admin/dashboard.rb +++ b/app/admin/dashboard.rb @@ -11,6 +11,43 @@ ActiveAdmin.register_page "Dashboard" do 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. # # columns do diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2c24e4d..356cbd7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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. allow_browser versions: :modern before_action :set_locale + after_action :track_action + + protected + + def track_action + ahoy.track "Viewed Application", request.path_parameters + end private diff --git a/app/controllers/cities_controller.rb b/app/controllers/cities_controller.rb index 12acd0f..fa3d6a1 100644 --- a/app/controllers/cities_controller.rb +++ b/app/controllers/cities_controller.rb @@ -24,6 +24,11 @@ class CitiesController < ApplicationController def show @city = City.friendly.find(params[:id]) + ahoy.track "View City", { + city_id: @city.id, + name: @city.name, + event_type: 'city_view' + } set_meta_tags( title: @city.name, diff --git a/app/controllers/weather_arts_controller.rb b/app/controllers/weather_arts_controller.rb index 0cf9a2e..58673fb 100644 --- a/app/controllers/weather_arts_controller.rb +++ b/app/controllers/weather_arts_controller.rb @@ -2,6 +2,18 @@ class WeatherArtsController < ApplicationController def show @city = City.friendly.find(params[:city_id]) @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( 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.", diff --git a/app/models/ahoy/event.rb b/app/models/ahoy/event.rb new file mode 100644 index 0000000..1509070 --- /dev/null +++ b/app/models/ahoy/event.rb @@ -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 diff --git a/app/models/ahoy/visit.rb b/app/models/ahoy/visit.rb new file mode 100644 index 0000000..4e5aefc --- /dev/null +++ b/app/models/ahoy/visit.rb @@ -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 diff --git a/app/models/city.rb b/app/models/city.rb index 9be6e39..1b6ae13 100644 --- a/app/models/city.rb +++ b/app/models/city.rb @@ -5,6 +5,9 @@ class City < ApplicationRecord 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 validates :name, presence: true @@ -17,6 +20,23 @@ class City < ApplicationRecord scope :by_country, ->(country_id) { where(country_id: country_id) } 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 name end @@ -65,4 +85,12 @@ class City < ApplicationRecord def latest_weather_art weather_arts.order(weather_date: :desc).first 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 diff --git a/app/models/weather_art.rb b/app/models/weather_art.rb index ca9c1e2..a297b0b 100644 --- a/app/models/weather_art.rb +++ b/app/models/weather_art.rb @@ -5,9 +5,28 @@ class WeatherArt < ApplicationRecord belongs_to :city 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 :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? weather_date_changed? || city_id_changed? || super end @@ -23,4 +42,12 @@ class WeatherArt < ApplicationRecord 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" ] 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 diff --git a/app/workers/clean_ahoy_data_worker.rb b/app/workers/clean_ahoy_data_worker.rb new file mode 100644 index 0000000..5b5a8ce --- /dev/null +++ b/app/workers/clean_ahoy_data_worker.rb @@ -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 \ No newline at end of file diff --git a/config/initializers/ahoy.rb b/config/initializers/ahoy.rb new file mode 100644 index 0000000..1d934bd --- /dev/null +++ b/config/initializers/ahoy.rb @@ -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 diff --git a/config/sidekiq_scheduler.yml b/config/sidekiq_scheduler.yml index 7cbbe62..1a6af0b 100644 --- a/config/sidekiq_scheduler.yml +++ b/config/sidekiq_scheduler.yml @@ -9,4 +9,10 @@ refresh_sitemap: class: RefreshSitemapWorker queue: default description: "Refresh sitemap daily" + enabled: true + +clean_ahoy_data: + cron: '0 0 * * 0' + class: CleanAhoyDataWorker + queue: default enabled: true \ No newline at end of file diff --git a/db/migrate/20250126155239_create_ahoy_visits_and_events.rb b/db/migrate/20250126155239_create_ahoy_visits_and_events.rb new file mode 100644 index 0000000..a9876e1 --- /dev/null +++ b/db/migrate/20250126155239_create_ahoy_visits_and_events.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 9a3025e..72b69c1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "namespace" 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 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| t.string "name" t.float "latitude"