diff --git a/app/assets/javascripts/discourse/app/components/about-page.gjs b/app/assets/javascripts/discourse/app/components/about-page.gjs
index 5d929aaa44c..42be96476b9 100644
--- a/app/assets/javascripts/discourse/app/components/about-page.gjs
+++ b/app/assets/javascripts/discourse/app/components/about-page.gjs
@@ -1,5 +1,6 @@
import Component from "@glimmer/component";
import { hash } from "@ember/helper";
+import { service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import AboutPageUsers from "discourse/components/about-page-users";
import PluginOutlet from "discourse/components/plugin-outlet";
@@ -20,6 +21,8 @@ export function clearAboutPageActivities() {
}
export default class AboutPage extends Component {
+ @service siteSettings;
+
get moderatorsCount() {
return this.args.model.moderators.length;
}
@@ -115,6 +118,20 @@ export default class AboutPage extends Component {
},
];
+ if (this.siteSettings.display_eu_visitor_stats) {
+ list.splice(2, 0, {
+ icon: "user-secret",
+ class: "visitors",
+ activityText: I18n.messageFormat("about.activities.visitors_MF", {
+ total_count: this.args.model.stats.visitors_7_days,
+ eu_count: this.args.model.stats.eu_visitors_7_days,
+ total_formatted_number: number(this.args.model.stats.visitors_7_days),
+ eu_formatted_number: number(this.args.model.stats.eu_visitors_7_days),
+ }),
+ period: I18n.t("about.activities.periods.last_7_days"),
+ });
+ }
+
return list.concat(this.siteActivitiesFromPlugins());
}
diff --git a/app/assets/javascripts/discourse/app/controllers/about.js b/app/assets/javascripts/discourse/app/controllers/about.js
index aa203fc2932..6d211c17ed8 100644
--- a/app/assets/javascripts/discourse/app/controllers/about.js
+++ b/app/assets/javascripts/discourse/app/controllers/about.js
@@ -23,4 +23,15 @@ export default class AboutController extends Controller {
return null;
}
}
+
+ @discourseComputed(
+ "model.stats.visitors_30_days",
+ "model.stats.eu_visitors_30_days"
+ )
+ statsTableFooter(all, eu) {
+ return I18n.messageFormat("about.traffic_info_footer_MF", {
+ total_visitors: all,
+ eu_visitors: eu,
+ });
+ }
}
diff --git a/app/assets/javascripts/discourse/app/templates/about.hbs b/app/assets/javascripts/discourse/app/templates/about.hbs
index 023ebf79d27..135aa123cc5 100644
--- a/app/assets/javascripts/discourse/app/templates/about.hbs
+++ b/app/assets/javascripts/discourse/app/templates/about.hbs
@@ -139,6 +139,22 @@
{{number this.model.stats.active_users_30_days}} |
— |
+ {{#if this.siteSettings.display_eu_visitor_stats}}
+
+ {{i18n "about.visitor_count"}} |
+ {{number this.model.stats.visitors_last_day}} |
+ {{number this.model.stats.visitors_7_days}} |
+ {{number this.model.stats.visitors_30_days}} |
+ — |
+
+
+ {{i18n "about.eu_visitor_count"}} |
+ {{number this.model.stats.eu_visitors_last_day}} |
+ {{number this.model.stats.eu_visitors_7_days}} |
+ {{number this.model.stats.eu_visitors_30_days}} |
+ — |
+
+ {{/if}}
{{i18n "about.like_count"}} |
{{number this.model.stats.likes_last_day}} |
@@ -170,6 +186,10 @@
{{/each}}
+ {{#if this.siteSettings.display_eu_visitor_stats}}
+
+ {{/if}}
{{/if}}
diff --git a/app/assets/stylesheets/common/base/about.scss b/app/assets/stylesheets/common/base/about.scss
index b70f50a5510..e3a015c5e34 100644
--- a/app/assets/stylesheets/common/base/about.scss
+++ b/app/assets/stylesheets/common/base/about.scss
@@ -113,5 +113,9 @@ section.about {
}
}
}
+
+ .stats-table-footer {
+ max-width: 30em;
+ }
}
}
diff --git a/app/models/application_request.rb b/app/models/application_request.rb
index a789ceb3346..b471314a1a0 100644
--- a/app/models/application_request.rb
+++ b/app/models/application_request.rb
@@ -59,6 +59,17 @@ class ApplicationRequest < ActiveRecord::Base
s
end
+
+ def self.request_type_count_for_period(type, since)
+ id = self.req_types[type]
+ if !id
+ raise ArgumentError.new(
+ "unknown request type #{type.inspect} in ApplicationRequest.req_types",
+ )
+ end
+
+ self.where(req_type: id).where("date >= ?", since).sum(:count)
+ end
end
# == Schema Information
diff --git a/app/models/stat.rb b/app/models/stat.rb
index e425f7e5e26..0ab2a5c09d2 100644
--- a/app/models/stat.rb
+++ b/app/models/stat.rb
@@ -40,7 +40,7 @@ class Stat
end
def self.core_stats
- [
+ list = [
Stat.new("topics", show_in_ui: true, expose_via_api: true) { Statistics.topics },
Stat.new("posts", show_in_ui: true, expose_via_api: true) { Statistics.posts },
Stat.new("users", show_in_ui: true, expose_via_api: true) { Statistics.users },
@@ -50,6 +50,19 @@ class Stat
Statistics.participating_users
end,
]
+
+ if SiteSetting.display_eu_visitor_stats
+ list.concat(
+ [
+ Stat.new("visitors", show_in_ui: true, expose_via_api: true) { Statistics.visitors },
+ Stat.new("eu_visitors", show_in_ui: true, expose_via_api: true) do
+ Statistics.eu_visitors
+ end,
+ ],
+ )
+ end
+
+ list
end
def self._api_stats
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 5ac999b5a54..4692c81bb36 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -342,6 +342,16 @@ en:
post_count: "Posts"
user_count: "Sign-ups"
active_user_count: "Active users"
+ visitor_count: "Visitors"
+ eu_visitor_count: "Visitors from European Union"
+ traffic_info_footer_MF: |
+ In the last 6 months, this site has served content to an average of approximately { total_visitors, plural,
+ one {# people}
+ other {# people}
+ } each month, with an average of approximately { eu_visitors, plural,
+ one {# people}
+ other {# people}
+ } from the European Union.
contact: "Contact us"
contact_info: "In the event of a critical issue or urgent matter affecting this site, please contact %{contact_info}."
site_activity: "Site activity"
@@ -363,6 +373,14 @@ en:
likes:
one: "%{formatted_number} like"
other: "%{formatted_number} likes"
+ visitors_MF: |
+ { total_count, plural,
+ one {{total_formatted_number} visitor}
+ other {{total_formatted_number} visitors}
+ }, about { eu_count, plural,
+ one {{eu_formatted_number}}
+ other {{eu_formatted_number}}
+ } from the EU
periods:
last_7_days: "in the last 7 days"
today: "today"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 88b44f595ba..8a8a48a0539 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2209,6 +2209,7 @@ en:
tos_url: "If you have a Terms of Service document hosted elsewhere that you want to use, provide the full URL here."
privacy_policy_url: "If you have a Privacy Policy document hosted elsewhere that you want to use, provide the full URL here."
log_anonymizer_details: "Whether to keep a user's details in the log after being anonymized."
+ display_eu_visitor_stats: "Show number of global and EU visitors on the /about page."
newuser_spam_host_threshold: "How many times a new user can post a link to the same host within their `newuser_spam_host_threshold` posts before being considered spam."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index ccd5a440256..3d5c6e80375 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2503,6 +2503,10 @@ legal:
default: false
hidden: true
client: true
+ display_eu_visitor_stats:
+ default: false
+ client: true
+ hidden: true
backups:
enable_backups:
diff --git a/lib/statistics.rb b/lib/statistics.rb
index c366cb13366..1beda7cd6b7 100644
--- a/lib/statistics.rb
+++ b/lib/statistics.rb
@@ -1,6 +1,36 @@
# frozen_string_literal: true
class Statistics
+ EU_COUNTRIES = %w[
+ AT
+ BE
+ BG
+ CY
+ CZ
+ DE
+ DK
+ EE
+ ES
+ FI
+ FR
+ GR
+ HR
+ HU
+ IE
+ IT
+ LT
+ LU
+ LV
+ MT
+ NL
+ PL
+ PT
+ RO
+ SE
+ SI
+ SK
+ ]
+
def self.active_users
{
last_day: User.where("last_seen_at > ?", 1.day.ago).count,
@@ -56,6 +86,58 @@ class Statistics
}
end
+ def self.visitors
+ periods = [[1.day.ago, :last_day], [7.days.ago, :"7_days"], [30.days.ago, :"30_days"]]
+
+ periods
+ .map do |(period, key)|
+ anon_page_views =
+ ApplicationRequest.request_type_count_for_period(:page_view_anon_browser, period)
+
+ logged_in_visitors = logged_in_visitors_count(period)
+ next key, anon_page_views if logged_in_visitors == 0
+
+ logged_in_page_views =
+ ApplicationRequest.request_type_count_for_period(:page_view_logged_in_browser, period)
+ next key, anon_page_views + logged_in_visitors if logged_in_page_views == 0
+
+ total_visitors = logged_in_visitors
+ avg_logged_in_page_view_per_user = logged_in_page_views.to_f / logged_in_visitors
+ anon_visitors = (anon_page_views / avg_logged_in_page_view_per_user).round
+ total_visitors += anon_visitors
+ [key, total_visitors]
+ end
+ .to_h
+ end
+
+ def self.eu_visitors
+ periods = [[1.day.ago, :last_day], [7.days.ago, :"7_days"], [30.days.ago, :"30_days"]]
+
+ periods
+ .map do |(period, key)|
+ logged_in_page_views =
+ ApplicationRequest.request_type_count_for_period(:page_view_logged_in_browser, period)
+ anon_page_views =
+ ApplicationRequest.request_type_count_for_period(:page_view_anon_browser, period)
+
+ all_logged_in_visitors = logged_in_visitors_count(period)
+ eu_logged_in_visitors = eu_logged_in_visitors_count(period)
+
+ next key, 0 if all_logged_in_visitors == 0 || eu_logged_in_visitors == 0
+ next key, eu_logged_in_visitors if logged_in_page_views == 0
+
+ avg_logged_in_page_view_per_user = logged_in_page_views / all_logged_in_visitors.to_f
+
+ eu_logged_in_visitors_ratio = eu_logged_in_visitors / all_logged_in_visitors.to_f
+
+ eu_anon_visitors =
+ ((anon_page_views / avg_logged_in_page_view_per_user) * eu_logged_in_visitors_ratio).round
+ eu_visitors = eu_logged_in_visitors + eu_anon_visitors
+ [key, eu_visitors]
+ end
+ .to_h
+ end
+
private
def self.participating_users_count(date)
@@ -75,4 +157,27 @@ class Statistics
DB.query_single(sql, date: date, action_types: UserAction::USER_ACTED_TYPES).first
end
+
+ def self.logged_in_visitors_count(since)
+ DB.query_single(<<~SQL, since:).first
+ SELECT COUNT(DISTINCT user_id)
+ FROM user_visits
+ WHERE visited_at >= :since
+ SQL
+ end
+
+ def self.eu_logged_in_visitors_count(since)
+ results = DB.query_hash(<<~SQL, since:)
+ SELECT DISTINCT(user_id), ip_address
+ FROM user_visits uv
+ INNER JOIN users u
+ ON u.id = uv.user_id
+ WHERE visited_at >= :since AND ip_address IS NOT NULL
+ SQL
+
+ results.reduce(0) do |sum, hash|
+ ip_info = DiscourseIpInfo.get(hash["ip_address"].to_s)
+ sum + (EU_COUNTRIES.include?(ip_info[:country_code]) ? 1 : 0)
+ end
+ end
end
diff --git a/spec/lib/statistics_spec.rb b/spec/lib/statistics_spec.rb
index 657eec99abf..02c568c8d1c 100644
--- a/spec/lib/statistics_spec.rb
+++ b/spec/lib/statistics_spec.rb
@@ -1,7 +1,59 @@
# frozen_string_literal: true
RSpec.describe Statistics do
- describe "#participating_users" do
+ def create_page_views_and_user_visit_records(date, users)
+ freeze_time(date - 50.minutes) do
+ 2.times { ApplicationRequest.increment!(:page_view_anon_browser) }
+ ApplicationRequest.increment!(:page_view_logged_in_browser)
+ end
+
+ freeze_time(date - 3.days) do
+ ApplicationRequest.increment!(:page_view_anon_browser)
+ 5.times { ApplicationRequest.increment!(:page_view_logged_in_browser) }
+ end
+
+ freeze_time(date - 6.days) do
+ 3.times { ApplicationRequest.increment!(:page_view_anon_browser) }
+ 4.times { ApplicationRequest.increment!(:page_view_logged_in_browser) }
+ end
+
+ freeze_time(date - 8.days) do
+ ApplicationRequest.increment!(:page_view_anon_browser)
+ ApplicationRequest.increment!(:page_view_logged_in_browser)
+ end
+
+ freeze_time(date - 15.days) do
+ 4.times { ApplicationRequest.increment!(:page_view_anon_browser) }
+ 3.times { ApplicationRequest.increment!(:page_view_logged_in_browser) }
+ end
+
+ freeze_time(date - 31.days) do
+ ApplicationRequest.increment!(:page_view_anon_browser)
+ ApplicationRequest.increment!(:page_view_logged_in_browser)
+ end
+
+ UserVisit.create!(user_id: users[0].id, visited_at: date - 50.minute)
+
+ UserVisit.create!(user_id: users[0].id, visited_at: date - 36.hours)
+ UserVisit.create!(user_id: users[1].id, visited_at: date - 2.day)
+ UserVisit.create!(user_id: users[0].id, visited_at: date - 4.days)
+ UserVisit.create!(user_id: users[2].id, visited_at: date - 6.days)
+ UserVisit.create!(user_id: users[3].id, visited_at: date - 3.days)
+ UserVisit.create!(user_id: users[3].id, visited_at: date - 5.days)
+ UserVisit.create!(user_id: users[1].id, visited_at: date - 66.hours)
+
+ UserVisit.create!(user_id: users[2].id, visited_at: date - 8.days)
+ UserVisit.create!(user_id: users[3].id, visited_at: date - 13.days)
+ UserVisit.create!(user_id: users[0].id, visited_at: date - 24.days)
+ UserVisit.create!(user_id: users[4].id, visited_at: date - 19.days)
+
+ UserVisit.create!(user_id: users[2].id, visited_at: date - 31.days)
+ end
+
+ fab!(:users) { Fabricate.times(5, :user) }
+ let(:date) { DateTime.parse("2024-03-01 13:00") }
+
+ describe ".participating_users" do
it "returns no participating users by default" do
pu = described_class.participating_users
expect(pu[:last_day]).to eq(0)
@@ -29,4 +81,138 @@ RSpec.describe Statistics do
expect(described_class.participating_users[:last_day]).to eq(1)
end
end
+
+ describe ".visitors" do
+ before do
+ ApplicationRequest.enable
+ create_page_views_and_user_visit_records(date, users)
+ end
+
+ after { ApplicationRequest.disable }
+
+ it "estimates the number of visitors for each of the previous 1 day, 7 days and 30 days periods" do
+ freeze_time(date) do
+ visitors = described_class.visitors
+
+ # anon page views: 2
+ # logged-in page views: 1
+ # logged-in visitors: 1
+ # we can estimate the number of unique anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor.
+ # in this case, the estimated number of anon visitors is 2 / (1 / 1) = 2.
+ # total visitors = logged-in visitors (1) + estimated anon visitors (2) = 3
+ expect(visitors[:last_day]).to eq(3)
+
+ # anon page views: 6
+ # logged-in page views: 10
+ # logged-in visitors: 4
+ # we can estimate the number of unique anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor.
+ # in this case, the estimated number of anon visitors is 6 / (10 / 4) ~= 2.
+ # total visitors = logged-in visitors (4) + estimated anon visitors (2) = 6
+ expect(visitors[:"7_days"]).to eq(6)
+
+ # anon page views: 11
+ # logged-in page views: 14
+ # logged-in visitors: 5
+ # we can estimate the number of unique anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor.
+ # in this case, the estimated number of anon visitors is 11 / (14 / 5) ~= 4.
+ # total visitors = logged-in visitors (5) + estimated anon visitors (4) = 9
+ expect(visitors[:"30_days"]).to eq(9)
+ end
+ end
+
+ it "is the same as the number of anon page views when there are no logged in visitors" do
+ freeze_time(date) do
+ UserVisit.delete_all
+
+ visitors = described_class.visitors
+
+ expect(visitors[:last_day]).to eq(2)
+ expect(visitors[:"7_days"]).to eq(6)
+ expect(visitors[:"30_days"]).to eq(11)
+ end
+ end
+ end
+
+ describe ".eu_visitors" do
+ before do
+ ApplicationRequest.enable
+ create_page_views_and_user_visit_records(date, users)
+
+ users[0].update!(ip_address: IPAddr.new("60.23.1.42"))
+ users[1].update!(ip_address: IPAddr.new("90.19.255.63"))
+ users[2].update!(ip_address: IPAddr.new("8.33.134.244"))
+ users[3].update!(ip_address: IPAddr.new("2.74.0.98"))
+ users[4].update!(ip_address: IPAddr.new("88.82.3.101"))
+
+ # EU IP addresses
+ DiscourseIpInfo.stubs(:get).with("60.23.1.42").returns({ country_code: "FR" }) # users[0]
+ DiscourseIpInfo.stubs(:get).with("2.74.0.98").returns({ country_code: "NL" }) # users[3]
+ DiscourseIpInfo.stubs(:get).with("88.82.3.101").returns({ country_code: "DE" }) # users[4]
+
+ # non-EU IP addresses
+ DiscourseIpInfo.stubs(:get).with("90.19.255.63").returns({ country_code: "US" }) # users[1]
+ DiscourseIpInfo.stubs(:get).with("8.33.134.244").returns({ country_code: "SA" }) # users[2]
+ end
+
+ after { ApplicationRequest.disable }
+
+ it "estimates the number of EU visitors for each of the previous 1 day, 7 days and 30 days periods" do
+ freeze_time(date) do
+ eu_visitors = described_class.eu_visitors
+
+ # anon page views: 2
+ # logged-in page views: 1
+ # logged-in visitors: 1
+ # EU logged-in visitors: 1 (users[0])
+ # we can estimate the number of unique EU anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor, then multiplying the result by the ratio
+ # of EU logged-in visitors to all logged-in visitors.
+ # in this case, the estimated number of EU anon visitors is 2 / (1 / 1) * (1 / 1) = 2
+ # total EU visitors = EU logged-in visitors (1) + estimated EU anon visitors (2) = 3
+ expect(eu_visitors[:last_day]).to eq(3)
+
+ # anon page views: 6
+ # logged-in page views: 10
+ # logged-in visitors: 4
+ # EU logged-in visitors: 2 (users[0], users[3])
+ # we can estimate the number of unique EU anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor, then multiplying the result by the ratio
+ # of EU logged-in visitors to all logged-in visitors.
+ # in this case, the estimated number of EU anon visitors is 6 / (10 / 4) * (2 / 4) ~= 1
+ # total EU visitors = EU logged-in visitors (2) + estimated EU anon visitors (1) = 3
+ expect(eu_visitors[:"7_days"]).to eq(3)
+
+ # anon page views: 11
+ # logged-in page views: 14
+ # logged-in visitors: 5
+ # EU logged-in visitors: 3 (users[0], users[3], users[4])
+ # we can estimate the number of unique EU anon visitors by dividing the
+ # number of anon page views by the average number of logged-in page
+ # views per logged-in visitor, then multiplying the result by the ratio
+ # of EU logged-in visitors to all logged-in visitors.
+ # in this case, the estimated number of EU anon visitors is 11 / (14 / 5) * (3 / 5) ~= 1
+ # total EU visitors = EU logged-in visitors (3) + estimated EU anon visitors (2) = 5
+ expect(eu_visitors[:"30_days"]).to eq(5)
+ end
+ end
+
+ it "returns 0 for EU visitors when there are no logged-in users" do
+ freeze_time(date) do
+ UserVisit.delete_all
+
+ eu_visitors = described_class.eu_visitors
+ expect(eu_visitors[:last_day]).to eq(0)
+ expect(eu_visitors[:"7_days"]).to eq(0)
+ expect(eu_visitors[:"30_days"]).to eq(0)
+ end
+ end
+ end
end
diff --git a/spec/system/about_page_spec.rb b/spec/system/about_page_spec.rb
index fd1c17440be..02c92f0c7bf 100644
--- a/spec/system/about_page_spec.rb
+++ b/spec/system/about_page_spec.rb
@@ -127,6 +127,31 @@ describe "About page", type: :system do
end
end
+ describe "visitors" do
+ context "when the display_eu_visitor_stats setting is disabled" do
+ before { SiteSetting.display_eu_visitor_stats = false }
+
+ it "doesn't show the row" do
+ about_page.visit
+
+ expect(about_page.site_activities).to have_no_activity_item("visitors")
+ end
+ end
+
+ context "when the display_eu_visitor_stats setting is enabled" do
+ before { SiteSetting.display_eu_visitor_stats = true }
+
+ it "shows the row" do
+ about_page.visit
+
+ expect(about_page.site_activities).to have_activity_item("visitors")
+ expect(about_page.site_activities.visitors).to have_text(
+ "1 visitor, about 0 from the EU",
+ )
+ end
+ end
+ end
+
describe "active users" do
before do
User.update_all(last_seen_at: 1.month.ago)
diff --git a/spec/system/page_objects/components/about_page_site_activity.rb b/spec/system/page_objects/components/about_page_site_activity.rb
index 5e0a9a5d7d0..794aca0a25c 100644
--- a/spec/system/page_objects/components/about_page_site_activity.rb
+++ b/spec/system/page_objects/components/about_page_site_activity.rb
@@ -23,6 +23,13 @@ module PageObjects
)
end
+ def visitors
+ AboutPageSiteActivityItem.new(
+ container.find(".about__activities-item.visitors"),
+ translation_key: nil,
+ )
+ end
+
def active_users
AboutPageSiteActivityItem.new(
container.find(".about__activities-item.active-users"),
@@ -51,6 +58,14 @@ module PageObjects
translation_key:,
)
end
+
+ def has_activity_item?(name)
+ container.has_css?(".about__activities-item.#{name}")
+ end
+
+ def has_no_activity_item?(name)
+ container.has_no_css?(".about__activities-item.#{name}")
+ end
end
end
end
diff --git a/spec/system/page_objects/components/about_page_site_activity_item.rb b/spec/system/page_objects/components/about_page_site_activity_item.rb
index b0f9b07c644..7ae821fd304 100644
--- a/spec/system/page_objects/components/about_page_site_activity_item.rb
+++ b/spec/system/page_objects/components/about_page_site_activity_item.rb
@@ -16,6 +16,10 @@ module PageObjects
)
end
+ def has_text?(text)
+ container.find(".about__activities-item-count").has_text?(text)
+ end
+
def has_1_day_period?
period_element.has_text?(I18n.t("js.about.activities.periods.today"))
end