0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/spec/lib/middleware/request_tracker_spec.rb
Alan Guo Xiang Tan 539eff3f93
FEATURE: Show bounce rate and average session duration on the dashboard (#40936)
This PR adds two KPI tiles to the Traffic section — bounce rate and
average session duration — measured per browsing session as real engaged
time: the seconds a tab is both visible and focused.

It builds on the per-session engagement core already captures
(`browser_pageview_session_engagements`), so the only new table is its
daily summary `browser_pageview_session_engagement_daily_rollups` —
additive, no existing schema changes.

Key technical changes:

1. Reuse core's capture. Engaged time is already recorded per session by
core's `human-activity-tracker` → `/srv/se` beacon →
`browser_pageview_session_engagements`; this PR adds no second tracker,
endpoint, table, or site setting.

2. Key engagement capture on `persist_browser_pageview_events` alone.
`Middleware::RequestTracker#is_engagement_tracking_request?` and the
`discourse-engagement-tracking-enabled` meta tag no longer also require
the `dashboard_improvements` upcoming change, so engagement is captured
wherever pageview events are persisted rather than only where the
redesigned dashboard is enabled.

3. Pre-compute in a scheduled job. The existing
`Jobs::MaintainBrowserPageviewRollups` job, which already maintains the
country and referrer rollups on the same cadence and setting gate, now
also rolls sessions into
`browser_pageview_session_engagement_daily_rollups`, one row per `(date,
logged_in)`, so the dashboard reads this small summary instead of
scanning raw events. A session is bounced when it has a single pageview
and under ten engaged seconds (no engagement counts as zero). Sessions
that started within the last ten minutes are held out of aggregation
until a later run, so a live session is not counted as a zero-engagement
bounce before the client's first engagement flush (three minutes in) has
arrived. Engagement rolls up only from the first engagement row's date
forward, because pageview rows predate this feature and have no
engagement data — counting them would make all of history look like
instant bounces.

4. Show the tiles only when there's data. They render when
`persist_browser_pageview_events` is on, alongside the existing Direct
traffic KPI, with a neutral placeholder until the first visits are
recorded; duration is formatted as `Xm Ys`.
2026-07-03 10:49:32 +08:00

2131 lines
72 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Middleware::RequestTracker do
def env(opts = {})
path = opts.delete(:path) || "/path?bla=1"
create_request_env(path: path).merge(
"HTTP_HOST" => "http://test.com",
"HTTP_USER_AGENT" =>
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36",
"REQUEST_METHOD" => "GET",
"HTTP_ACCEPT" =>
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"rack.input" => StringIO.new,
).merge(opts)
end
before do
ApplicationRequest.enable
CachedCounting.reset
CachedCounting.enable
SiteSetting.persist_browser_pageview_events = false
end
after do
CachedCounting.reset
ApplicationRequest.disable
CachedCounting.disable
end
describe "full request" do
it "can handle rogue user agents" do
agent = (+"Evil Googlebot String \xc3\x28").force_encoding("Windows-1252")
middleware =
Middleware::RequestTracker.new(->(env) { ["200", { "Content-Type" => "text/html" }, [""]] })
middleware.call(env("HTTP_USER_AGENT" => agent))
CachedCounting.flush
expect(WebCrawlerRequest.where(user_agent: agent.encode("utf-8")).count).to eq(1)
end
it "can handle rogue user agents with invalid bytes sequences" do
agent = (+"Evil Googlebot String \xc3\x28").force_encoding("ASCII") # encode("utf-8") -> InvalidByteSequenceError
expect {
middleware =
Middleware::RequestTracker.new(
->(env) { ["200", { "Content-Type" => "text/html" }, [""]] },
)
middleware.call(env("HTTP_USER_AGENT" => agent))
CachedCounting.flush
expect(
WebCrawlerRequest.where(
user_agent: agent.encode("utf-8", invalid: :replace, undef: :replace),
).count,
).to eq(1)
}.not_to raise_error
end
it "can handle rogue user agents with undefined characters in the destination encoding" do
agent = (+"Evil Googlebot String \xc3\x28").force_encoding("ASCII-8BIT") # encode("utf-8") -> UndefinedConversionError
expect {
middleware =
Middleware::RequestTracker.new(
->(env) { ["200", { "Content-Type" => "text/html" }, [""]] },
)
middleware.call(env("HTTP_USER_AGENT" => agent))
CachedCounting.flush
expect(
WebCrawlerRequest.where(
user_agent: agent.encode("utf-8", invalid: :replace, undef: :replace),
).count,
).to eq(1)
}.not_to raise_error
end
end
describe "log_request" do
before do
freeze_time
ApplicationRequest.clear_cache!
end
def log_tracked_view(val)
data =
Middleware::RequestTracker.get_data(
env("HTTP_DISCOURSE_TRACK_VIEW" => val),
["200", { "Content-Type" => "text/html" }],
0.2,
)
Middleware::RequestTracker.log_request(data)
end
it "can exclude/include based on custom header" do
log_tracked_view("true")
log_tracked_view("1")
log_tracked_view("false")
log_tracked_view("0")
CachedCounting.flush
expect(ApplicationRequest.page_view_anon.first.count).to eq(2)
expect(ApplicationRequest.page_view_anon_browser.first.count).to eq(2)
end
it "adds the appropriate response header based on explicit tracking (AJAX requests, BPVs)" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, headers = middleware.call(env("HTTP_DISCOURSE_TRACK_VIEW" => "1"))
expect(status).to eq(200)
expect(headers["X-Discourse-TrackView"]).to eq("1")
expect(headers["X-Discourse-BrowserPageView"]).to eq("1")
end
it "adds the appropriate response header based on implicit tracking (HTML requests)" do
middleware =
Middleware::RequestTracker.new(
lambda { |env| [200, { "Content-Type" => "text/html" }, ["OK"]] },
)
status, headers = middleware.call(env)
expect(status).to eq(200)
expect(headers["X-Discourse-TrackView"]).to eq("1")
expect(headers["X-Discourse-BrowserPageView"]).to eq(nil)
end
it "adds the appropriate response header based on deferred tracking (MiniProfiler piggyback, BPVs)" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, headers = middleware.call(env("HTTP_DISCOURSE_TRACK_VIEW_DEFERRED" => "1"))
expect(status).to eq(200)
expect(headers["X-Discourse-TrackView"]).to eq(nil)
expect(headers["X-Discourse-BrowserPageView"]).to eq("1")
end
it "adds the appropriate response headers for MessageBus requests with deferred tracking" do
app =
lambda do |env|
headers = MessageBus.extra_response_headers_lookup.call(env)
[200, headers, ["OK"]]
end
middleware = Middleware::RequestTracker.new(app)
status, headers =
middleware.call(
env("HTTP_DISCOURSE_TRACK_VIEW_DEFERRED" => "1", :path => "/message-bus/abcde/poll"),
)
expect(status).to eq(200)
expect(headers["X-Discourse-BrowserPageView"]).to eq("1")
end
it "adds the appropriate response headers for MessageBus requests with regular tracking" do
app =
lambda do |env|
headers = MessageBus.extra_response_headers_lookup.call(env)
[200, headers, ["OK"]]
end
middleware = Middleware::RequestTracker.new(app)
status, headers =
middleware.call(env("HTTP_DISCOURSE_TRACK_VIEW" => "1", :path => "/message-bus/abcde/poll"))
expect(status).to eq(200)
expect(headers["X-Discourse-BrowserPageView"]).to eq("1")
expect(headers["X-Discourse-TrackView"]).to eq("1")
end
it "does not add these response headers when skipping the request tracker" do
app =
lambda do |env|
headers = MessageBus.extra_response_headers_lookup.call(env)
[200, headers, ["OK"]]
end
middleware = Middleware::RequestTracker.new(app)
status, headers =
middleware.call(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
:path => "/message-bus/abcde/poll",
"discourse.request_tracker.skip" => true,
),
)
expect(status).to eq(200)
expect(headers["X-Discourse-BrowserPageView"]).to eq(nil)
expect(headers["X-Discourse-TrackView"]).to eq(nil)
end
it "can log requests correctly" do
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "AdsBot-Google (+http://www.google.com/adsbot.html)"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
data =
Middleware::RequestTracker.get_data(
env("HTTP_DISCOURSE_TRACK_VIEW" => "1"),
["200", {}],
0.1,
)
Middleware::RequestTracker.log_request(data)
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_USER_AGENT" =>
"Mozilla/5.0 (iPhone; CPU iPhone OS 8_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12B410 Safari/600.1.4",
),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
# /srv/status is never a tracked view because content-type is text/plain
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "kube-probe/1.18", "REQUEST_URI" => "/srv/status"),
["200", { "Content-Type" => "text/plain" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.http_total.first.count).to eq(4)
expect(ApplicationRequest.http_2xx.first.count).to eq(4)
expect(ApplicationRequest.page_view_anon.first.count).to eq(2)
expect(ApplicationRequest.page_view_crawler.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_mobile.first.count).to eq(1)
expect(ApplicationRequest.page_view_crawler.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_browser.first.count).to eq(1)
end
it "logs deferred pageviews correctly" do
data =
Middleware::RequestTracker.get_data(
env(:path => "/message-bus/abcde/poll", "HTTP_DISCOURSE_TRACK_VIEW_DEFERRED" => "1"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
expect(data[:deferred_track_view]).to eq(true)
CachedCounting.flush
expect(ApplicationRequest.page_view_anon_browser.first.count).to eq(1)
end
describe "embed mode pageviews" do
it "does not leak the initial embed HTML load into the legacy page_view_anon counter" do
data =
Middleware::RequestTracker.get_data(
env(path: "/t/topic-slug/1?embed_mode=true"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.page_view_anon.sum(:count)).to eq(0)
expect(ApplicationRequest.page_view_embed.sum(:count)).to eq(0)
end
it "counts deferred pageview with embed header as page_view_embed" do
data =
Middleware::RequestTracker.get_data(
env(
:path => "/message-bus/abcde/poll",
"HTTP_DISCOURSE_TRACK_VIEW_DEFERRED" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_EMBED" => "true",
),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.page_view_embed.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_browser.sum(:count)).to eq(0)
end
it "counts explicit XHR pageview with embed header only once as page_view_embed" do
data =
Middleware::RequestTracker.get_data(
env("HTTP_DISCOURSE_TRACK_VIEW" => "1", "HTTP_DISCOURSE_TRACK_VIEW_EMBED" => "true"),
["200", {}],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.page_view_embed.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon.sum(:count)).to eq(0)
expect(ApplicationRequest.page_view_anon_browser.sum(:count)).to eq(0)
end
it "does not defer a topic view for embed browser pageviews" do
TopicsController.expects(:defer_topic_view).never
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_EMBED" => "true",
"HTTP_DISCOURSE_TRACK_VIEW_TOPIC_ID" => "42",
),
["200", {}],
0.1,
)
Middleware::RequestTracker.log_request(data)
end
it "counts beacon pageview with embed flag as page_view_embed" do
SiteSetting.dashboard_improvements = true
SiteSetting.trigger_browser_pageview_events = true
body = {
session_id: "abc",
url: "https://example.com/t/slug/1",
referrer: "https://host.example/page",
embed: true,
}.to_json
data =
Middleware::RequestTracker.get_data(
env(
"REQUEST_METHOD" => "POST",
:path => Discourse.beacon_pv_tracking_path,
"rack.input" => StringIO.new(body),
),
["204", {}],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(data[:is_embed]).to eq(true)
expect(ApplicationRequest.page_view_embed.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_browser_beacon.sum(:count)).to eq(0)
end
it "survives requests with a missing or unreadable body" do
# POST without a rack.input must not raise while detecting `is_embed` —
# we only care about the query string, not the body.
expect {
Middleware::RequestTracker.get_data(
env(
"REQUEST_METHOD" => "POST",
:path => "/srv/something?embed_mode=true",
"rack.input" => nil,
),
["200", {}],
0.1,
)
}.not_to raise_error
end
it "survives malformed query strings while detecting embed mode" do
data = nil
expect {
data =
Middleware::RequestTracker.get_data(
env(path: "/?foo=1&foo%5B1%5D=2"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
}.not_to raise_error
expect(data[:is_embed]).to eq(false)
end
it "still counts crawlers as page_view_crawler even on embed URLs" do
data =
Middleware::RequestTracker.get_data(
env(
:path => "/t/topic-slug/1?embed_mode=true",
"HTTP_USER_AGENT" => "AdsBot-Google (+http://www.google.com/adsbot.html)",
),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.page_view_crawler.first.count).to eq(1)
expect(ApplicationRequest.page_view_embed.sum(:count)).to eq(0)
end
end
it "logs API requests correctly" do
data =
Middleware::RequestTracker.get_data(
env("_DISCOURSE_API" => "1"),
["200", { "Content-Type" => "text/json" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
data =
Middleware::RequestTracker.get_data(
env("_DISCOURSE_API" => "1"),
["404", { "Content-Type" => "text/json" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
data =
Middleware::RequestTracker.get_data(env("_DISCOURSE_USER_API" => "1"), ["200", {}], 0.1)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.http_total.first.count).to eq(3)
expect(ApplicationRequest.http_2xx.first.count).to eq(2)
expect(ApplicationRequest.api.first.count).to eq(2)
expect(ApplicationRequest.user_api.first.count).to eq(1)
end
it "can log Discourse user agent requests correctly" do
# log discourse api agents as crawlers for page view stats...
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "DiscourseAPI Ruby Gem 0.19.0"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
CachedCounting.reset
expect(ApplicationRequest.page_view_crawler.first.count).to eq(1)
# ...but count our mobile app user agents as regular visits
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "Mozilla/5.0 AppleWebKit/605.1.15 Mobile/15E148 DiscourseHub)"),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
CachedCounting.flush
expect(ApplicationRequest.page_view_crawler.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon.first.count).to eq(1)
end
describe "topic views" do
fab!(:topic)
fab!(:post) { Fabricate(:post, topic: topic) }
fab!(:user) { Fabricate(:user, active: true) }
let!(:auth_cookie) do
token = UserAuthToken.generate!(user_id: user.id)
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: 5.minutes.ago,
)
end
def log_topic_view(authenticated: false, deferred: false)
headers = { "action_dispatch.remote_ip" => "127.0.0.1" }
headers["HTTP_COOKIE"] = "_t=#{auth_cookie};" if authenticated
if deferred
headers["HTTP_DISCOURSE_TRACK_VIEW"] = "1"
headers["HTTP_DISCOURSE_TRACK_VIEW_DEFERRED"] = "1"
headers["HTTP_DISCOURSE_TRACK_VIEW_TOPIC_ID"] = topic.id
path = "/message-bus/abcde/poll"
else
headers["HTTP_DISCOURSE_TRACK_VIEW"] = "1"
headers["HTTP_DISCOURSE_TRACK_VIEW_TOPIC_ID"] = topic.id
path = URI.parse(topic.url).path
end
data =
Middleware::RequestTracker.get_data(
env(path: path, **headers),
["200", { "Content-Type" => "text/html" }],
0.1,
)
Middleware::RequestTracker.log_request(data)
data
end
it "logs deferred topic views correctly for logged in users" do
data = log_topic_view(authenticated: true, deferred: true)
expect(data[:topic_id]).to eq(topic.id)
expect(data[:request_remote_ip]).to eq("127.0.0.1")
expect(data[:current_user_id]).to eq(user.id)
CachedCounting.flush
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id, ip_address: nil)).to eq(
true,
)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 0,
logged_in_views: 1,
viewed_at: Time.zone.now.to_date,
),
).to eq(true)
end
it "does not log deferred topic views for topics the user cannot access" do
topic.update!(category: Fabricate(:private_category, group: Fabricate(:group)))
log_topic_view(authenticated: true, deferred: true)
CachedCounting.flush
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id, ip_address: nil)).to eq(
false,
)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 0,
logged_in_views: 1,
viewed_at: Time.zone.now.to_date,
),
).to eq(false)
end
it "logs deferred topic views correctly for anonymous" do
data = log_topic_view(authenticated: false, deferred: true)
expect(data[:topic_id]).to eq(topic.id)
expect(data[:request_remote_ip]).to eq("127.0.0.1")
expect(data[:current_user_id]).to eq(nil)
CachedCounting.flush
expect(
TopicViewItem.exists?(topic_id: topic.id, user_id: nil, ip_address: "127.0.0.1"),
).to eq(true)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 1,
logged_in_views: 0,
viewed_at: Time.zone.now.to_date,
),
).to eq(true)
end
it "does not log deferred topic views for topics the anonymous user cannot access" do
topic.update!(category: Fabricate(:private_category, group: Fabricate(:group)))
log_topic_view(authenticated: false, deferred: true)
CachedCounting.flush
expect(
TopicViewItem.exists?(topic_id: topic.id, user_id: nil, ip_address: "127.0.0.1"),
).to eq(false)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 1,
logged_in_views: 0,
viewed_at: Time.zone.now.to_date,
),
).to eq(false)
end
it "logs explicit topic views correctly for logged in users" do
data = log_topic_view(authenticated: true, deferred: false)
expect(data[:topic_id]).to eq(topic.id)
expect(data[:request_remote_ip]).to eq("127.0.0.1")
expect(data[:current_user_id]).to eq(user.id)
CachedCounting.flush
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id, ip_address: nil)).to eq(
true,
)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 0,
logged_in_views: 1,
viewed_at: Time.zone.now.to_date,
),
).to eq(true)
end
it "does not log explicit topic views for topics the user cannot access" do
topic.update!(category: Fabricate(:private_category, group: Fabricate(:group)))
log_topic_view(authenticated: true, deferred: false)
CachedCounting.flush
expect(TopicViewItem.exists?(topic_id: topic.id, user_id: user.id, ip_address: nil)).to eq(
false,
)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 0,
logged_in_views: 1,
viewed_at: Time.zone.now.to_date,
),
).to eq(false)
end
it "logs explicit topic views correctly for anonymous" do
data = log_topic_view(authenticated: false, deferred: false)
expect(data[:topic_id]).to eq(topic.id)
expect(data[:request_remote_ip]).to eq("127.0.0.1")
expect(data[:current_user_id]).to eq(nil)
CachedCounting.flush
expect(
TopicViewItem.exists?(topic_id: topic.id, user_id: nil, ip_address: "127.0.0.1"),
).to eq(true)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 1,
logged_in_views: 0,
viewed_at: Time.zone.now.to_date,
),
).to eq(true)
end
it "does not log explicit topic views for topics the anonymous user cannot access" do
topic.update!(category: Fabricate(:private_category, group: Fabricate(:group)))
log_topic_view(authenticated: false, deferred: false)
CachedCounting.flush
expect(
TopicViewItem.exists?(topic_id: topic.id, user_id: nil, ip_address: "127.0.0.1"),
).to eq(false)
expect(
TopicViewStat.exists?(
topic_id: topic.id,
anonymous_views: 1,
logged_in_views: 0,
viewed_at: Time.zone.now.to_date,
),
).to eq(false)
end
end
context "when ignoring anonymous page views" do
let(:anon_data) do
Middleware::RequestTracker.get_data(
env(
"HTTP_USER_AGENT" =>
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36",
),
["200", { "Content-Type" => "text/html" }],
0.1,
)
end
let(:logged_in_data) do
user = Fabricate(:user, active: true)
token = UserAuthToken.generate!(user_id: user.id)
cookie =
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: 5.minutes.ago,
)
Middleware::RequestTracker.get_data(
env(
"HTTP_USER_AGENT" =>
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36",
"HTTP_COOKIE" => "_t=#{cookie};",
),
["200", { "Content-Type" => "text/html" }],
0.1,
)
end
it "does not ignore anonymous requests for public sites" do
SiteSetting.login_required = false
Middleware::RequestTracker.log_request(anon_data)
Middleware::RequestTracker.log_request(logged_in_data)
CachedCounting.flush
expect(ApplicationRequest.http_total.first.count).to eq(2)
expect(ApplicationRequest.http_2xx.first.count).to eq(2)
expect(ApplicationRequest.page_view_logged_in.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon.first.count).to eq(1)
end
it "ignores anonymous requests for private sites" do
SiteSetting.login_required = true
Middleware::RequestTracker.log_request(anon_data)
Middleware::RequestTracker.log_request(logged_in_data)
CachedCounting.flush
expect(ApplicationRequest.http_total.first.count).to eq(2)
expect(ApplicationRequest.http_2xx.first.count).to eq(2)
expect(ApplicationRequest.page_view_logged_in.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon.first).to eq(nil)
end
end
describe "browser_pageview event" do
def log_browser_pageview(data)
Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] }).log_later(data, {}, nil)
end
context "when SiteSetting.trigger_browser_pageview_events is true" do
before { SiteSetting.trigger_browser_pageview_events = true }
it "triggers event for anonymous user page views when `login_required` site setting is false" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
DiscourseIpInfo.stubs(:get).returns(country_code: "AU")
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://example.com",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events.length).to eq(1)
event = events[0][:params].first
expect(event[:user_id]).to be_nil
expect(event[:session_id]).to eq(session_id)
expect(event[:url]).to eq("https://discourse.org")
expect(event[:referrer]).to eq("https://example.com")
expect(event).to have_key(:ip_address)
expect(event[:country_code]).to eq("AU")
expect(event[:user_agent]).to be_present
end
it "does not trigger event for anonymous user page views when `login_required` site setting is true" do
SiteSetting.login_required = true
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://example.com",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events).to be_empty
end
it "truncates session id, url, referrer, ip address and user agent" do
Middleware::AnonymousCache::Helper.any_instance.expects(:is_crawler?).returns(false)
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => "A" * 50,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "A" * 5000,
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "A" * 5000,
"HTTP_USER_AGENT" => "A" * 5000,
"action_dispatch.remote_ip" => "1" * 50,
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events.length).to eq(1)
event = events[0][:params].first
expect(event[:url].length).to eq(Middleware::RequestTracker::MAX_URL_LENGTH)
expect(event[:referrer].length).to eq(Middleware::RequestTracker::MAX_URL_LENGTH)
expect(event[:session_id].length).to eq(Middleware::RequestTracker::MAX_SESSION_ID_LENGTH)
expect(event[:user_agent].length).to eq(Middleware::RequestTracker::MAX_USER_AGENT_LENGTH)
expect(event[:ip_address].length).to eq(Middleware::RequestTracker::MAX_IP_ADDRESS_LENGTH)
end
it "triggers event for logged-in user page views" do
user = Fabricate(:user, active: true)
DiscourseIpInfo.stubs(:get).returns(country_code: "DE")
token = UserAuthToken.generate!(user_id: user.id)
cookie =
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: 5.minutes.ago,
)
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_COOKIE" => "_t=#{cookie};",
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://example.com",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events.length).to eq(1)
event = events[0][:params].first
expect(event[:user_id]).to eq(user.id)
expect(event[:url]).to eq("https://discourse.org")
expect(event[:referrer]).to eq("https://example.com")
expect(event).to have_key(:ip_address)
expect(event[:country_code]).to eq("DE")
expect(event[:user_agent]).to be_present
end
it "does not trigger event for crawler page views" do
data =
Middleware::RequestTracker.get_data(
env("HTTP_USER_AGENT" => "Googlebot"),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events.length).to eq(0)
end
end
context "when SiteSetting.trigger_browser_pageview_events is false" do
it "does not trigger events" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://example.com",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events = DiscourseEvent.track_events(:browser_pageview) { log_browser_pageview(data) }
expect(events.length).to eq(0)
end
end
context "when SiteSetting.persist_browser_pageview_events is true" do
before do
SiteSetting.persist_browser_pageview_events = true
BrowserPageviewEvent.clear_queued!
Discourse.clear_readonly!
end
after do
BrowserPageviewEvent.clear_queued!
Discourse.clear_readonly!
end
def flush_browser_pageview_events
Jobs::FlushBrowserPageviewEvents.new.execute({})
end
it "creates a BrowserPageviewEvent row and does not fire the :browser_pageview event" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
DiscourseIpInfo.stubs(:get).returns(country_code: "AU")
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://example.com",
"action_dispatch.remote_ip" => "1.2.3.4",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events =
DiscourseEvent.track_events(:browser_pageview) do
expect { log_browser_pageview(data) }.to change { BrowserPageviewEvent.count }.by(1)
end
expect(events).to be_empty
expect(BrowserPageviewEvent.queued_count).to eq(0)
event = BrowserPageviewEvent.last
expect(event.session_id).to eq(session_id)
expect(event.url).to eq("https://discourse.org")
expect(event.referrer).to eq("https://example.com")
expect(event.country_code).to eq("AU")
expect(event.user_agent).to be_present
expect(event.ip_address.to_s).to eq("1.2.3.4")
expect(BrowserPageviewEvent.sources[event.source]).to eq(
BrowserPageviewEvent::SOURCE_PIGGYBACK,
)
end
it "skips persisted browser pageviews without a URL" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW_DEFERRED" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"action_dispatch.remote_ip" => "1.2.3.4",
),
["204", {}],
0.2,
)
expect { Middleware::RequestTracker.log_request(data) }.not_to change {
BrowserPageviewEvent.count
}
end
it "populates normalized_referrer via BrowserPageviewReferrerInspector" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"HTTP_DISCOURSE_TRACK_VIEW_REFERRER" => "https://www.example.com/path?utm_source=x",
"action_dispatch.remote_ip" => "1.2.3.4",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
log_browser_pageview(data)
event = BrowserPageviewEvent.last
expect(event.referrer).to eq("https://www.example.com/path?utm_source=x")
expect(event.normalized_referrer).to eq("example.com/path")
expect(event.normalized_referrer_version).to eq(BrowserPageviewReferrerInspector::VERSION)
end
it "stores nil normalized_referrer when the referrer is blank" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"action_dispatch.remote_ip" => "1.2.3.4",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
log_browser_pageview(data)
event = BrowserPageviewEvent.last
expect(event.normalized_referrer).to be_nil
end
it "takes precedence even when trigger_browser_pageview_events is also true" do
SiteSetting.trigger_browser_pageview_events = true
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"action_dispatch.remote_ip" => "1.2.3.4",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
events =
DiscourseEvent.track_events(:browser_pageview) do
expect { log_browser_pageview(data) }.to change { BrowserPageviewEvent.count }.by(1)
end
expect(events).to be_empty
end
it "queues browser pageviews while PostgreSQL is readonly" do
session_id = "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx"
Discourse.enable_readonly_mode(Discourse::PG_READONLY_MODE_KEY)
data =
Middleware::RequestTracker.get_data(
env(
"HTTP_DISCOURSE_TRACK_VIEW" => "1",
"HTTP_DISCOURSE_TRACK_VIEW_SESSION_ID" => session_id,
"HTTP_DISCOURSE_TRACK_VIEW_URL" => "https://discourse.org",
"action_dispatch.remote_ip" => "1.2.3.4",
),
["200", { "Content-Type" => "text/html" }],
0.2,
)
expect { log_browser_pageview(data) }.not_to change { BrowserPageviewEvent.count }
expect(BrowserPageviewEvent.queued_count).to eq(1)
Discourse.disable_readonly_mode(Discourse::PG_READONLY_MODE_KEY)
expect { flush_browser_pageview_events }.to change { BrowserPageviewEvent.count }.by(1)
expect(BrowserPageviewEvent.queued_count).to eq(0)
end
end
end
end
describe "beacon pageview tracking via /srv/pv" do
before do
SiteSetting.dashboard_improvements = true
SiteSetting.trigger_browser_pageview_events = true
freeze_time
ApplicationRequest.clear_cache!
end
def beacon_env(body_hash, extra = {})
json_body = JSON.generate(body_hash)
env(
{
:path => "/srv/pv",
"HTTP_HOST" => "test.localhost",
"REQUEST_METHOD" => "POST",
"CONTENT_TYPE" => "application/json",
"rack.input" => StringIO.new(json_body),
}.merge(extra),
)
end
let(:same_origin) { { "HTTP_ORIGIN" => "http://test.localhost" } }
it "returns 204 and does not call the app" do
app_called = false
middleware =
Middleware::RequestTracker.new(
lambda do |env|
app_called = true
[200, {}, ["OK"]]
end,
)
status, = middleware.call(beacon_env({}, same_origin))
expect(status).to eq(204)
expect(app_called).to eq(false)
end
it "returns 204 for beacon requests in a subfolder setup" do
set_subfolder "/forum"
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, = middleware.call(beacon_env({}, same_origin.merge(path: "/forum/srv/pv")))
expect(status).to eq(204)
end
it "handles malformed JSON body gracefully" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, =
middleware.call(
env(
:path => "/srv/pv",
"HTTP_HOST" => "test.localhost",
"HTTP_ORIGIN" => "http://test.localhost",
"REQUEST_METHOD" => "POST",
"CONTENT_TYPE" => "application/json",
"rack.input" => StringIO.new("not json"),
),
)
expect(status).to eq(204)
end
it "increments beacon-specific counters and fires beacon event with correct data" do
SiteSetting.trigger_browser_pageview_events = true
DiscourseIpInfo.stubs(:get).returns(country_code: "US")
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
events =
DiscourseEvent.track_events(:beacon_browser_pageview) do
middleware.call(
beacon_env(
{
url: "https://test.com/t/topic/123",
referrer: "https://test.com/",
session_id: "abc123",
topic_id: 123,
},
same_origin,
),
)
end
CachedCounting.flush
expect(ApplicationRequest.page_view_anon_browser_beacon.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon.first).to be_nil
expect(ApplicationRequest.page_view_anon_browser.first).to be_nil
event = events[0][:params].last
expect(event[:url]).to eq("https://test.com/t/topic/123")
expect(event[:referrer]).to eq("https://test.com/")
expect(event[:session_id]).to eq("abc123")
expect(event[:topic_id]).to eq(123)
expect(event[:country_code]).to eq("US")
expect(event[:user_agent]).to eq(
"Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36",
)
end
it "persists beacon pageviews to browser_pageview_events with beacon source" do
SiteSetting.persist_browser_pageview_events = true
DiscourseIpInfo.stubs(:get).returns(country_code: "US")
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
expect {
middleware.call(
beacon_env(
{
url: "https://test.com/t/topic/123",
referrer: "https://test.com/",
session_id: "abc123",
topic_id: 123,
},
same_origin.merge("action_dispatch.remote_ip" => "1.2.3.4"),
),
)
}.to change { BrowserPageviewEvent.count }.by(1)
event = BrowserPageviewEvent.last
expect(event.url).to eq("https://test.com/t/topic/123")
expect(event.referrer).to eq("https://test.com/")
expect(event.session_id).to eq("abc123")
expect(event.topic_id).to eq(123)
expect(event.country_code).to eq("US")
expect(event.ip_address.to_s).to eq("1.2.3.4")
expect(event.source).to eq("beacon")
end
it "increments legacy and BPV counters from non-beacon requests" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
middleware.call(env("HTTP_DISCOURSE_TRACK_VIEW" => "1"))
CachedCounting.flush
expect(ApplicationRequest.page_view_anon.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_browser.first.count).to eq(1)
expect(ApplicationRequest.page_view_anon_browser_beacon.first).to be_nil
end
it "skips beacon page view when the remote IP resolves to a crawler ASN" do
DiscourseIpInfo.stubs(:get).returns({ asn: SiteSetting.crawler_asns_map.first.to_i })
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
middleware.call(beacon_env({}, same_origin.merge("action_dispatch.remote_ip" => "1.2.3.4")))
CachedCounting.flush
expect(ApplicationRequest.page_view_anon_browser_beacon.first).to be_nil
end
it "counts beacon page view when the remote IP is not a crawler ASN" do
DiscourseIpInfo.stubs(:get).returns({ asn: 1 })
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
middleware.call(beacon_env({}, same_origin.merge("action_dispatch.remote_ip" => "1.2.3.4")))
CachedCounting.flush
expect(ApplicationRequest.page_view_anon_browser_beacon.first.count).to eq(1)
end
it "returns 403 for cross-origin beacon requests" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, = middleware.call(beacon_env({}, { "HTTP_ORIGIN" => "https://evil.example" }))
CachedCounting.flush
expect(status).to eq(403)
expect(ApplicationRequest.page_view_anon_browser_beacon.first).to be_nil
end
it "returns 403 when Origin and Referer are both absent" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, = middleware.call(beacon_env({}))
CachedCounting.flush
expect(status).to eq(403)
expect(ApplicationRequest.page_view_anon_browser_beacon.first).to be_nil
end
it "returns 403 when Origin matches the request Host but differs from the canonical hostname" do
SiteSetting.force_hostname = "canonical.example"
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, =
middleware.call(
beacon_env({}, { "HTTP_HOST" => "evil.example", "HTTP_ORIGIN" => "http://evil.example" }),
)
CachedCounting.flush
expect(status).to eq(403)
expect(ApplicationRequest.page_view_anon_browser_beacon.first).to be_nil
end
it "accepts beacon when Origin is absent but Referer is same-origin" do
middleware = Middleware::RequestTracker.new(lambda { |env| [200, {}, ["OK"]] })
status, =
middleware.call(beacon_env({}, { "HTTP_REFERER" => "http://test.localhost/some/page" }))
expect(status).to eq(204)
end
context "when dashboard_improvements is disabled" do
before { SiteSetting.dashboard_improvements = false }
it "returns the app's response for beacon requests instead of 204" do
app_called = false
middleware =
Middleware::RequestTracker.new(
lambda do |env|
app_called = true
[404, {}, ["unknown app path"]]
end,
)
status, = middleware.call(beacon_env({}))
expect(status).to eq(404)
expect(app_called).to eq(true)
end
end
context "when browser pageview persistence and events are disabled" do
before do
SiteSetting.persist_browser_pageview_events = false
SiteSetting.trigger_browser_pageview_events = false
end
it "returns the app's response for beacon requests instead of 204" do
app_called = false
middleware =
Middleware::RequestTracker.new(
lambda do |env|
app_called = true
[404, {}, ["unknown app path"]]
end,
)
status, = middleware.call(beacon_env({}))
expect(status).to eq(404)
expect(app_called).to eq(true)
end
end
end
describe "rate limiting" do
let(:fake_logger) { FakeLogger.new }
before do
RateLimiter.enable
RateLimiter.clear_all_global!
Rails.logger.broadcast_to(fake_logger)
# rate limiter tests depend on checks for retry-after
# they can be sensitive to clock skew during test runs
freeze_time_safe
end
after { Rails.logger.stop_broadcasting_to(fake_logger) }
let(:middleware) do
app = lambda { |env| [200, {}, ["OK"]] }
Middleware::RequestTracker.new(app)
end
it "does nothing if configured to do nothing" do
global_setting :max_reqs_per_ip_mode, "none"
global_setting :max_reqs_per_ip_per_10_seconds, 1
status, _ = middleware.call(env)
status, _ = middleware.call(env)
expect(status).to eq(200)
end
it "blocks private IPs if not skipped" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "warn+block"
global_setting :max_reqs_rate_limit_on_private, true
addresses = %w[
127.1.2.3
127.0.0.2
192.168.1.2
10.0.1.2
172.16.9.8
172.19.1.2
172.20.9.8
172.29.1.2
172.30.9.8
172.31.1.2
]
warn_count = 1
addresses.each do |addr|
env1 = env("REMOTE_ADDR" => addr)
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(fake_logger.warnings.count { |w| w.include?("Global rate limit exceeded") }).to eq(
warn_count,
)
expect(status).to eq(429)
warn_count += 1
end
end
it "blocks if the ip isn't static skipped" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "block"
env1 = env("REMOTE_ADDR" => "1.1.1.1")
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(status).to eq(429)
end
it "doesn't block if rate limiter is enabled but IP is on the static exception list" do
stub_const(
Middleware::RequestTracker,
"STATIC_IP_SKIPPER",
"177.33.14.73 191.209.88.192/30".split.map { |ip| IPAddr.new(ip) },
) do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "block"
env1 = env("REMOTE_ADDR" => "177.33.14.73")
env2 = env("REMOTE_ADDR" => "191.209.88.194")
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, _ = middleware.call(env2)
expect(status).to eq(200)
status, _ = middleware.call(env2)
expect(status).to eq(200)
end
end
describe "crawler rate limits" do
context "when there are multiple matching crawlers" do
before { SiteSetting.slow_down_crawler_user_agents = "badcrawler2|badcrawler22" }
it "only checks limits for the first match" do
env = env("HTTP_USER_AGENT" => "badcrawler")
status, _ = middleware.call(env)
expect(status).to eq(200)
end
end
it "compares user agents in a case-insensitive manner" do
SiteSetting.slow_down_crawler_user_agents = "BaDCRawLer"
env1 = env("HTTP_USER_AGENT" => "bADcrAWLer")
env2 = env("HTTP_USER_AGENT" => "bADcrAWLer")
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, _ = middleware.call(env2)
expect(status).to eq(429)
end
end
describe "register_ip_skipper" do
before do
Middleware::RequestTracker.register_ip_skipper { |ip| ip == "1.1.1.2" }
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "block"
end
after { Middleware::RequestTracker.unregister_ip_skipper }
it "won't block if the ip is skipped" do
env1 = env("REMOTE_ADDR" => "1.1.1.2")
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(status).to eq(200)
end
it "blocks if the ip isn't skipped" do
env1 = env("REMOTE_ADDR" => "1.1.1.1")
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(status).to eq(429)
end
end
it "does nothing for private IPs if skipped" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "warn+block"
global_setting :max_reqs_rate_limit_on_private, false
addresses = %w[
127.1.2.3
127.0.3.1
192.168.1.2
10.0.1.2
172.16.9.8
172.19.1.2
172.20.9.8
172.29.1.2
172.30.9.8
172.31.1.2
]
addresses.each do |addr|
env1 = env("REMOTE_ADDR" => addr)
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(fake_logger.warnings.count { |w| w.include?("Global rate limit exceeded") }).to eq(0)
expect(status).to eq(200)
end
end
it "does warn if rate limiter is enabled via warn+block" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "warn+block"
env1 = env("REMOTE_ADDR" => "192.0.2.42")
status, _ = middleware.call(env1)
status, headers = middleware.call(env1)
expect(fake_logger.warnings.count { |w| w.include?("Global rate limit exceeded") }).to eq(1)
expect(status).to eq(429)
expect(headers["Retry-After"]).to eq("10")
end
it "does warn if rate limiter is enabled" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "warn"
env1 = env("REMOTE_ADDR" => "192.0.2.42")
status, _ = middleware.call(env1)
status, _ = middleware.call(env1)
expect(fake_logger.warnings.count { |w| w.include?("Global rate limit exceeded") }).to eq(1)
expect(status).to eq(200)
end
it "allows assets for more requests" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "block"
global_setting :max_asset_reqs_per_ip_per_10_seconds, 3
env1 = env("REMOTE_ADDR" => "1.1.1.1", "DISCOURSE_IS_ASSET_PATH" => 1)
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, headers = middleware.call(env1)
expect(status).to eq(429)
expect(headers["Retry-After"]).to eq("10")
env2 = env("REMOTE_ADDR" => "1.1.1.1")
status, headers = middleware.call(env2)
expect(status).to eq(429)
expect(headers["Retry-After"]).to eq("10")
end
it "does block if rate limiter is enabled" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
global_setting :max_reqs_per_ip_mode, "block"
env1 = env("REMOTE_ADDR" => "1.1.1.1")
env2 = env("REMOTE_ADDR" => "1.1.1.2")
status, _ = middleware.call(env1)
expect(status).to eq(200)
status, headers = middleware.call(env1)
expect(status).to eq(429)
expect(headers["Retry-After"]).to eq("10")
status, _ = middleware.call(env2)
expect(status).to eq(200)
end
describe "diagnostic information" do
it "is included when the requests-per-10-seconds limit is reached" do
global_setting :max_reqs_per_ip_per_10_seconds, 1
called = 0
app =
lambda do |_|
called += 1
[200, {}, ["OK"]]
end
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
expect(called).to eq(1)
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(called).to eq(1)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("ip_10_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 10 seconds.
Error code: ip_10_secs_limit.
MSG
end
it "is included when the requests-per-minute limit is reached" do
global_setting :max_reqs_per_ip_per_minute, 1
called = 0
app =
lambda do |_|
called += 1
[200, {}, ["OK"]]
end
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
expect(called).to eq(1)
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(called).to eq(1)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("ip_60_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 60 seconds.
Error code: ip_60_secs_limit.
MSG
end
it "is included when the assets-requests-per-10-seconds limit is reached" do
global_setting :max_asset_reqs_per_ip_per_10_seconds, 1
called = 0
app =
lambda do |env|
called += 1
env["DISCOURSE_IS_ASSET_PATH"] = true
[200, {}, ["OK"]]
end
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
expect(called).to eq(1)
env = env("REMOTE_ADDR" => "1.1.1.1")
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(called).to eq(1)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("ip_assets_10_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 10 seconds.
Error code: ip_assets_10_secs_limit.
MSG
end
end
it "users with high enough trust level are not rate limited per ip" do
global_setting :max_reqs_per_ip_per_minute, 1
global_setting :skip_per_ip_rate_limit_trust_level, 3
envs =
3.times.map do |n|
user = Fabricate(:user, trust_level: 3)
token = UserAuthToken.generate!(user_id: user.id)
cookie =
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: 5.minutes.ago,
)
env("HTTP_COOKIE" => "_t=#{cookie}", "REMOTE_ADDR" => "1.1.1.1")
end
called = 0
app =
lambda do |env|
called += 1
[200, {}, ["OK"]]
end
envs.each do |env|
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
end
expect(called).to eq(3)
envs.each do |env|
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("user_60_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 60 seconds.
Error code: user_60_secs_limit.
MSG
end
expect(called).to eq(3)
end
it "falls back to IP rate limiting if the cookie is too old" do
unfreeze_time
global_setting :max_reqs_per_ip_per_minute, 1
global_setting :skip_per_ip_rate_limit_trust_level, 3
user = Fabricate(:user, trust_level: 3)
token = UserAuthToken.generate!(user_id: user.id)
cookie =
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: 5.minutes.ago,
)
env = env("HTTP_COOKIE" => "_t=#{cookie}", "REMOTE_ADDR" => "1.1.1.1")
called = 0
app =
lambda do |_|
called += 1
[200, {}, ["OK"]]
end
freeze_time(12.minutes.from_now) do
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("ip_60_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 60 seconds.
Error code: ip_60_secs_limit.
MSG
end
end
it "falls back to IP rate limiting if the cookie is tampered with" do
unfreeze_time
global_setting :max_reqs_per_ip_per_minute, 1
global_setting :skip_per_ip_rate_limit_trust_level, 3
user = Fabricate(:user, trust_level: 3)
token = UserAuthToken.generate!(user_id: user.id)
cookie =
create_auth_cookie(
token: token.unhashed_auth_token,
user_id: user.id,
trust_level: user.trust_level,
issued_at: Time.zone.now,
)
cookie = swap_2_different_characters(cookie)
env = env("HTTP_COOKIE" => "_t=#{cookie}", "REMOTE_ADDR" => "1.1.1.1")
called = 0
app =
lambda do |_|
called += 1
[200, {}, ["OK"]]
end
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env)
expect(status).to eq(200)
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env)
expect(status).to eq(429)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("ip_60_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 60 seconds.
Error code: ip_60_secs_limit.
MSG
end
context "for `add_request_rate_limiter` plugin API" do
after { described_class.reset_rate_limiters_stack }
it "can be used to add a custom rate limiter" do
global_setting :max_reqs_per_ip_per_minute, 1
plugin = Plugin::Instance.new
plugin.add_request_rate_limiter(
identifier: :crawlers,
key: ->(_request) { "crawlers" },
activate_when: ->(request) { request.user_agent =~ /crawler/ },
)
env1 = env("HTTP_USER_AGENT" => "some crawler")
called = 0
app =
lambda do |_|
called += 1
[200, {}, ["OK"]]
end
middleware = Middleware::RequestTracker.new(app)
status, = middleware.call(env1)
expect(status).to eq(200)
middleware = Middleware::RequestTracker.new(app)
status, headers, response = middleware.call(env1)
expect(status).to eq(429)
expect(headers["Discourse-Rate-Limit-Error-Code"]).to eq("crawlers_60_secs_limit")
expect(response.first).to eq(<<~MSG)
Slow down, you're making too many requests.
Please retry again in 60 seconds.
Error code: crawlers_60_secs_limit.
MSG
end
end
end
describe "callbacks" do
def app(result, sql_calls: 0, redis_calls: 0)
lambda do |env|
sql_calls.times { User.where(id: -100).pluck(:id) }
redis_calls.times { Discourse.redis.get("x") }
yield if block_given?
result
end
end
let(:logger) do
->(env, data) do
@env = env
@data = data
end
end
before { Middleware::RequestTracker.register_detailed_request_logger(logger) }
after { Middleware::RequestTracker.unregister_detailed_request_logger(logger) }
it "can report data from anon cache" do
Middleware::AnonymousCache.enable_anon_cache
cache = Middleware::AnonymousCache.new(app([200, {}, ["i am a thing"]]))
tracker = Middleware::RequestTracker.new(cache)
uri = "/path?#{SecureRandom.hex}"
request_params = { "a" => "b", "action" => "bob", "controller" => "jane" }
tracker.call(
env(
"REQUEST_URI" => uri,
"ANON_CACHE_DURATION" => 60,
"action_dispatch.request.parameters" => request_params,
),
)
expect(@data[:cache]).to eq("skip")
tracker.call(
env(
"REQUEST_URI" => uri,
"ANON_CACHE_DURATION" => 60,
"action_dispatch.request.parameters" => request_params,
),
)
expect(@data[:cache]).to eq("store")
tracker.call(env("REQUEST_URI" => uri, "ANON_CACHE_DURATION" => 60))
expect(@data[:cache]).to eq("true")
# not allowlisted
request_params.delete("a")
expect(@env["action_dispatch.request.parameters"]).to eq(request_params)
end
it "can correctly log detailed data" do
global_setting :enable_performance_http_headers, true
# ensure pg is warmed up with the select 1 query
User.where(id: -100).pluck(:id)
freeze_time
start = Time.now.to_f
freeze_time 1.minute.from_now
tracker = Middleware::RequestTracker.new(app([200, {}, []], sql_calls: 2, redis_calls: 2))
_, headers, _ =
tracker.call(
env(
"HTTP_X_REQUEST_START" => "t=#{start}",
Middleware::ProcessingRequest::REQUEST_QUEUE_SECONDS_ENV_KEY => 60,
),
)
expect(@data[:queue_seconds]).to eq(60)
timing = @data[:timing]
expect(timing[:total_duration]).to be > 0
expect(timing[:sql][:duration]).to be > 0
expect(timing[:sql][:calls]).to eq 2
expect(timing[:redis][:duration]).to be > 0
expect(timing[:redis][:calls]).to eq 2
expect(headers["X-Queue-Time"]).to eq("60.000000")
expect(headers["X-Redis-Calls"]).to eq("2")
expect(headers["X-Redis-Time"].to_f).to be > 0
expect(headers["X-Sql-Calls"]).to eq("2")
expect(headers["X-Sql-Time"].to_f).to be > 0
expect(headers["X-Runtime"].to_f).to be > 0
end
it "correctly logs GC stats when `instrument_gc_stat_per_request` site setting has been enabled" do
tracker =
Middleware::RequestTracker.new(
app([200, {}, []]) do
GC.start(full_mark: true) # Major GC
GC.start(full_mark: false) # Minor GC
end,
)
tracker.call(env)
expect(@data[:timing][:gc]).to eq(nil)
SiteSetting.instrument_gc_stat_per_request = true
tracker =
Middleware::RequestTracker.new(
app([200, {}, []]) do
GC.start(full_mark: true) # Major GC
GC.start(full_mark: false) # Minor GC
end,
)
tracker.call(env)
expect(@data[:timing][:gc][:time]).to be > 0.0
expect(@data[:timing][:gc][:major_count]).to eq(1)
expect(@data[:timing][:gc][:minor_count]).to eq(1)
end
it "can correctly log messagebus request types" do
tracker = Middleware::RequestTracker.new(app([200, {}, []]))
tracker.call(env(path: "/message-bus/abcde/poll"))
expect(@data[:is_background]).to eq(true)
expect(@data[:background_type]).to eq("message-bus")
tracker.call(env(path: "/message-bus/abcde/poll?dlp=t"))
expect(@data[:is_background]).to eq(true)
expect(@data[:background_type]).to eq("message-bus-dlp")
tracker.call(env("HTTP_DONT_CHUNK" => "True", :path => "/message-bus/abcde/poll"))
expect(@data[:is_background]).to eq(true)
expect(@data[:background_type]).to eq("message-bus-dontchunk")
end
end
describe "error handling" do
let(:fake_logger) { FakeLogger.new }
before { Rails.logger.broadcast_to(fake_logger) }
after { Rails.logger.stop_broadcasting_to(fake_logger) }
it "logs requests even if they cause exceptions" do
app = lambda { |env| raise RateLimiter::LimitExceeded, 1 }
tracker = Middleware::RequestTracker.new(app)
expect { tracker.call(env) }.to raise_error(RateLimiter::LimitExceeded)
CachedCounting.flush
expect(ApplicationRequest.stats["http_total_total"]).to eq(1)
expect(fake_logger.warnings).to be_empty
end
end
describe "session engagement tracking via /srv/se" do
before { SiteSetting.persist_browser_pageview_events = true }
def engagement_env(body_hash, extra = {})
env(
{
:path => "/srv/se",
"HTTP_HOST" => "test.localhost",
"REQUEST_METHOD" => "POST",
"CONTENT_TYPE" => "application/json",
"rack.input" => StringIO.new(JSON.generate(body_hash)),
}.merge(extra),
)
end
let(:same_origin) { { "HTTP_ORIGIN" => "http://test.localhost" } }
let(:payload) do
{
session_id: "sess-1",
mouse_move_events: 12,
click_events: 3,
key_events: 5,
scroll_events: 7,
touch_events: 0,
back_forward_events: 1,
engaged_seconds: 420,
time_to_first_interaction_ms: 800,
}
end
it "returns 204 and inserts the row for a same-origin request" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, = middleware.call(engagement_env(payload, same_origin))
expect(status).to eq(204)
}.to change { BrowserPageviewSessionEngagement.count }.by(1)
row = BrowserPageviewSessionEngagement.find_by(session_id: "sess-1")
expect(row.mouse_move_events).to eq(12)
expect(row.back_forward_events).to eq(1)
expect(row.engaged_seconds).to eq(420)
expect(row.time_to_first_interaction_ms).to eq(800)
end
it "updates the existing row on a later snapshot for the same session" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
middleware.call(engagement_env(payload, same_origin))
later = payload.merge(mouse_move_events: 40, engaged_seconds: 900)
expect { middleware.call(engagement_env(later, same_origin)) }.not_to change {
BrowserPageviewSessionEngagement.count
}
row = BrowserPageviewSessionEngagement.find_by(session_id: "sess-1")
expect(row.mouse_move_events).to eq(40)
expect(row.engaged_seconds).to eq(900)
end
it "returns 403 and writes nothing for a cross-origin request" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, = middleware.call(engagement_env(payload, "HTTP_ORIGIN" => "https://evil.example"))
expect(status).to eq(403)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
it "handles a malformed JSON body without writing a row" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, =
middleware.call(engagement_env({}, same_origin).merge("rack.input" => StringIO.new("x")))
expect(status).to eq(204)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
it "ignores a parseable JSON body that is not an object" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, =
middleware.call(
engagement_env({}, same_origin).merge("rack.input" => StringIO.new("[1,2,3]")),
)
expect(status).to eq(204)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
it "coerces string and float metric values to integers" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
middleware.call(
engagement_env(payload.merge(click_events: "9", engaged_seconds: 1234.9), same_origin),
)
expect(BrowserPageviewSessionEngagement.find_by(session_id: "sess-1")).to have_attributes(
click_events: 9,
engaged_seconds: 1234,
)
end
it "treats a null or blank time to first interaction as null rather than zero" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
[nil, ""].each do |value|
middleware.call(
engagement_env(payload.merge(time_to_first_interaction_ms: value), same_origin),
)
expect(
BrowserPageviewSessionEngagement.find_by(
session_id: "sess-1",
).time_to_first_interaction_ms,
).to be_nil
end
end
it "discards malformed-but-parseable payloads instead of raising in the deferred write" do
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
[
payload.merge(session_id: 123),
payload.merge(mouse_move_events: { "x" => 1 }),
payload.merge(mouse_move_events: 9_999_999_999),
].each do |malformed|
expect {
status, = middleware.call(engagement_env(malformed, same_origin))
expect(status).to eq(204)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
end
it "clamps engaged seconds to the configured maximum" do
SiteSetting.browser_pageview_max_engaged_seconds = 60
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
middleware.call(engagement_env(payload.merge(engaged_seconds: 5000), same_origin))
expect(BrowserPageviewSessionEngagement.find_by(session_id: "sess-1").engaged_seconds).to eq(
60,
)
end
it "writes nothing when the database is in readonly mode" do
Discourse.stubs(:pg_readonly_mode?).returns(true)
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, = middleware.call(engagement_env(payload, same_origin))
expect(status).to eq(204)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
it "does not intercept the request when persist_browser_pageview_events is disabled" do
SiteSetting.persist_browser_pageview_events = false
SiteSetting.trigger_browser_pageview_events = true
SiteSetting.dashboard_improvements = true
middleware = Middleware::RequestTracker.new(lambda { |_env| [200, {}, ["OK"]] })
expect {
status, = middleware.call(engagement_env(payload, same_origin))
expect(status).to eq(200)
}.not_to change { BrowserPageviewSessionEngagement.count }
end
end
end