Skip to content

Commit 4416b67

Browse files
committed
Fix sample rate calculator estimated traffic overshoot when date range is longer than site stats range
1 parent 7259b48 commit 4416b67

4 files changed

Lines changed: 75 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ All notable changes to this project will be documented in this file.
5252
- Fixed main graph being clipped when the browser's root font size is smaller than the default 16px
5353
- Fixed issue with users with billing role not being able to create personal segments
5454
- Fixed period arrow keys hijacking custom-range calendar
55+
- Fixed sample rate calculator overestimating traffic when the queried date range starts before the site's stats begin
5556

5657
## v3.2.0 - 2026-01-16
5758

extra/lib/plausible/stats/sampling.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,18 @@ defmodule Plausible.Stats.Sampling do
7070

7171
def fractional_sample_rate(traffic_30_day, query) do
7272
date_range = Query.date_range(query)
73-
duration = Date.diff(date_range.last, date_range.first)
73+
stats_start = query.site_native_stats_start_at
74+
75+
# A site's traffic before their stats begin date isn't queried,
76+
# so it shouldn't figure in the traffic estimation
77+
start_of_range_with_stats =
78+
if stats_start && Date.before?(date_range.first, stats_start) do
79+
stats_start
80+
else
81+
date_range.first
82+
end
83+
84+
duration = Date.diff(date_range.last, start_of_range_with_stats)
7485

7586
estimated_traffic = estimate_traffic(traffic_30_day, duration, query)
7687

lib/plausible/sites.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,9 @@ defmodule Plausible.Sites do
378378
def stats_start_date(site)
379379

380380
on_ee do
381-
# for now, we're going to always update consolidated views
381+
# for now, we're going to always update consolidated views,
382+
# though Repo.update! runs the actual update query only when
383+
# the value has changed
382384
def stats_start_date(%Site{consolidated: true} = site) do
383385
team = Repo.preload(site, :team).team
384386

test/plausible/stats/sampling_test.exs

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
defmodule Plausible.Stats.SamplingTest do
2-
use Plausible.DataCase, async: true
2+
use Plausible.DataCase, async: false
33

44
on_ee do
55
import Plausible.Stats.Sampling, only: [fractional_sample_rate: 2]
66
alias Plausible.Stats.{Query, DateTimeRange}
77

8-
describe "&fractional_sample_rate/2" do
9-
@threshold Plausible.Stats.Sampling.default_sample_threshold()
8+
@threshold Plausible.Stats.Sampling.default_sample_threshold()
109

10+
describe "&fractional_sample_rate/2 for date ranges of different lengths" do
1111
test "no traffic estimate" do
1212
assert fractional_sample_rate(nil, query(30)) == :no_sampling
1313
end
@@ -47,8 +47,7 @@ defmodule Plausible.Stats.SamplingTest do
4747
end
4848

4949
test "short durations" do
50-
assert fractional_sample_rate(@threshold * 15, query(1, unit: :hour)) ==
51-
:no_sampling
50+
assert fractional_sample_rate(@threshold * 15, query(1, unit: :hour)) == :no_sampling
5251
end
5352

5453
test "very low sampling rate" do
@@ -58,24 +57,56 @@ defmodule Plausible.Stats.SamplingTest do
5857
@filter ["is", "event:name", ["pageview"]]
5958
test "scales sampling rate according to query filters (when sampling adjustments are enabled)" do
6059
assert fractional_sample_rate(@threshold * 50, query(30, filters: [])) == 0.02
60+
assert fractional_sample_rate(@threshold * 50, query(30, filters: [@filter])) == 0.08
6161

62-
assert fractional_sample_rate(@threshold * 50, query(30, filters: [@filter])) ==
63-
0.08
64-
65-
assert fractional_sample_rate(
66-
@threshold * 50,
67-
query(30, filters: [@filter, @filter])
68-
) == 0.32
62+
assert fractional_sample_rate(@threshold * 50, query(30, filters: [@filter, @filter])) ==
63+
0.32
6964

7065
assert fractional_sample_rate(
7166
@threshold * 50,
7267
query(30, filters: [@filter, @filter, @filter])
7368
) == 0.32
7469

75-
assert fractional_sample_rate(
76-
@threshold * 10,
77-
query(30, filters: [@filter, @filter])
78-
) == :no_sampling
70+
assert fractional_sample_rate(@threshold * 10, query(30, filters: [@filter, @filter])) ==
71+
:no_sampling
72+
end
73+
end
74+
75+
describe "&fractional_sample_rate/2 clamps the range to the site's stats start" do
76+
test "a range with no known stats start uses the full requested range" do
77+
# `site_native_stats_start_at` is only nil in tests / for sites without
78+
# stats; nothing gets clamped, matching a stats start before the range.
79+
without_start = range_query(~D[2025-01-01], ~D[2026-07-15], nil)
80+
early_start = range_query(~D[2025-01-01], ~D[2026-07-15], ~N[1900-01-01 00:00:00])
81+
82+
assert fractional_sample_rate(@threshold, without_start) ==
83+
fractional_sample_rate(@threshold, early_start)
84+
end
85+
86+
test "a range starting after stats begin is unaffected by the clamp" do
87+
after_start = range_query(~D[2026-04-01], ~D[2026-07-15], ~N[2026-01-01 00:00:00])
88+
no_start = range_query(~D[2026-04-01], ~D[2026-07-15], nil)
89+
90+
assert fractional_sample_rate(@threshold, after_start) ==
91+
fractional_sample_rate(@threshold, no_start)
92+
end
93+
94+
test "a range starting before stats begin is treated as starting when stats begin" do
95+
stats_start = ~N[2026-01-01 00:00:00]
96+
range_end = ~D[2026-07-15]
97+
98+
from_stats_start =
99+
fractional_sample_rate(@threshold, range_query(~D[2026-01-01], range_end, stats_start))
100+
101+
from_1970 =
102+
fractional_sample_rate(@threshold, range_query(~D[1970-01-01], range_end, stats_start))
103+
104+
from_2025 =
105+
fractional_sample_rate(@threshold, range_query(~D[2025-01-01], range_end, stats_start))
106+
107+
assert from_stats_start == 0.15
108+
assert from_1970 == from_stats_start
109+
assert from_2025 == from_stats_start
79110
end
80111
end
81112

@@ -92,5 +123,17 @@ defmodule Plausible.Stats.SamplingTest do
92123
filters: filters
93124
}
94125
end
126+
127+
defp range_query(first_date, last_date, native_stats_start_at) do
128+
first = DateTime.new!(first_date, ~T[00:00:00], "Etc/UTC")
129+
last = DateTime.new!(last_date, ~T[00:00:00], "Etc/UTC")
130+
131+
%Query{
132+
utc_time_range: DateTimeRange.new!(first, last),
133+
timezone: "UTC",
134+
filters: [],
135+
site_native_stats_start_at: native_stats_start_at
136+
}
137+
end
95138
end
96139
end

0 commit comments

Comments
 (0)