PluginProbe ʕ •ᴥ•ʔ
Independent Analytics – WordPress Analytics Plugin / 2.8.4
Independent Analytics – WordPress Analytics Plugin v2.8.4
2.15.0 2.14.10 trunk 1.1 1.10 1.10.1 1.11 1.12 1.13 1.14 1.15 1.16 1.17 1.17.1 1.17.2 1.17.3 1.17.4 1.18 1.18.1 1.19.0 1.19.1 1.2 1.20.0 1.21.0 1.22.0 1.22.1 1.23.0 1.23.1 1.24.0 1.24.1 1.25.0 1.25.1 1.26.0 1.27.0 1.28.0 1.28.1 1.28.2 1.28.3 1.29.0 1.3 1.30.0 1.30.1 1.4 1.5 1.6 1.7 1.8 1.9 2.0.0 2.0.1 2.1.4 2.1.5 2.1.6 2.10.0 2.10.1 2.10.2 2.10.3 2.10.4 2.11.0 2.11.1 2.11.10 2.11.2 2.11.3 2.11.4 2.11.5 2.11.6 2.11.7 2.11.8 2.11.9 2.12.0 2.12.1 2.12.2 2.13.1 2.13.2 2.13.5 2.13.6 2.14.0 2.14.1 2.14.2 2.14.4 2.14.6 2.14.7 2.14.8 2.14.9 2.2.0 2.2.1 2.3.1 2.3.2 2.4.2 2.4.3 2.5.0 2.5.1 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.7.0 2.7.1 2.7.2 2.7.3 2.8.2 2.8.3 2.8.4 2.8.5 2.8.6 2.8.7 2.8.8 2.8.9 2.9.2 2.9.3 2.9.4 2.9.5 2.9.6 2.9.7
independent-analytics / IAWP / Statistics / Statistics.php
independent-analytics / IAWP / Statistics Last commit date
Intervals 1 year ago Campaign_Statistics.php 2 years ago City_Statistics.php 2 years ago Country_Statistics.php 2 years ago Device_Browser_Statistics.php 2 years ago Device_OS_Statistics.php 2 years ago Device_Type_Statistics.php 2 years ago Page_Statistics.php 2 years ago Referrer_Statistics.php 2 years ago Statistic.php 1 year ago Statistics.php 1 year ago
Statistics.php
305 lines
1 <?php
2
3 namespace IAWP\Statistics;
4
5 use DateInterval;
6 use DatePeriod;
7 use DateTime;
8 use IAWP\Date_Range\Date_Range;
9 use IAWP\Form_Submissions\Form;
10 use IAWP\Illuminate_Builder;
11 use IAWP\Plugin_Group;
12 use IAWP\Query;
13 use IAWP\Query_Taps;
14 use IAWP\Rows\Rows;
15 use IAWP\Statistics\Intervals\Interval;
16 use IAWP\Statistics\Intervals\Intervals;
17 use IAWP\Utils\Calculations;
18 use IAWPSCOPED\Illuminate\Database\Query\Builder;
19 use IAWPSCOPED\Illuminate\Database\Query\JoinClause;
20 use IAWPSCOPED\Illuminate\Support\Collection;
21 use IAWPSCOPED\Illuminate\Support\Str;
22 use IAWPSCOPED\Proper\Timezone;
23 use Throwable;
24 /** @internal */
25 abstract class Statistics
26 {
27 protected $date_range;
28 protected $rows;
29 protected $chart_interval;
30 private $statistics;
31 private $previous_period_statistics;
32 private $statistics_grouped_by_date_interval;
33 private $unfiltered_statistics;
34 private $previous_period_unfiltered_statistic;
35 private $unfiltered_statistics_grouped_by_date_interval;
36 private $statistic_instances;
37 // The biggest flaw here is that it requires two queries for the stats (current and previous) when
38 // that could be one. I think it would also be possible to reuse the rows query and not limit
39 // by 50 and just SUM() up all the stats columns for the quick stats. Maybe that would be faster
40 // even if two queries were still used. Needs testing.
41 public function __construct(Date_Range $date_range, ?Rows $rows = null, ?Interval $chart_interval = null)
42 {
43 $this->date_range = $date_range;
44 $this->rows = $rows;
45 $this->chart_interval = $chart_interval ?? Intervals::default_for($date_range->number_of_days());
46 if (\is_null($rows)) {
47 $this->statistics = $this->query($this->date_range);
48 $this->previous_period_statistics = $this->query($this->date_range->previous_period());
49 $this->statistics_grouped_by_date_interval = $this->query($this->date_range, null, \true);
50 } else {
51 $this->statistics = $this->query($this->date_range, $rows);
52 $this->previous_period_statistics = $this->query($this->date_range->previous_period(), $rows);
53 $this->statistics_grouped_by_date_interval = $this->query($this->date_range, $rows, \true);
54 $this->unfiltered_statistics = $this->query($this->date_range);
55 $this->previous_period_unfiltered_statistic = $this->query($this->date_range->previous_period());
56 $this->unfiltered_statistics_grouped_by_date_interval = $this->query($this->date_range, null, \true);
57 }
58 $this->statistic_instances = $this->make_statistic_instances();
59 }
60 /**
61 * @return Statistic[]
62 */
63 public function get_statistics() : array
64 {
65 return $this->statistic_instances;
66 }
67 public function get_grouped_statistics()
68 {
69 // This whole thing is a bit of a mess...
70 return Collection::make($this->statistic_instances)->groupBy(function (\IAWP\Statistics\Statistic $item, int $key) {
71 return Plugin_Group::get_plugin_group($item->plugin_group())->name();
72 })->map(function (Collection $group, $plugin_group) {
73 $items = $group->map(function (\IAWP\Statistics\Statistic $item) {
74 if (!$item->is_group_plugin_enabled()) {
75 return null;
76 }
77 return ['id' => $item->id(), 'name' => $item->name()];
78 })->filter();
79 if ($items->isEmpty()) {
80 return null;
81 }
82 return ['name' => $plugin_group, 'items' => $items->toArray()];
83 })->filter()->values()->toArray();
84 }
85 public function get_statistic(string $statistic_id) : ?\IAWP\Statistics\Statistic
86 {
87 foreach ($this->statistic_instances as $statistic_instance) {
88 if ($statistic_instance->id() == $statistic_id) {
89 return $statistic_instance;
90 }
91 }
92 return null;
93 }
94 public function has_filters() : bool
95 {
96 return !\is_null($this->rows);
97 }
98 public function chart_interval() : Interval
99 {
100 return $this->chart_interval;
101 }
102 /**
103 * I'm sure there's more we could do here. If you get a result back where there isn't a full
104 * page of results or where you're not paginating, then you can just count up the rows...
105 *
106 * @return int|null
107 */
108 public function total_number_of_rows() : ?int
109 {
110 $sessions_table = Query::get_table_name(Query::SESSIONS);
111 $views_table = Query::get_table_name(Query::VIEWS);
112 $column = $this->total_table_rows_column() ?? $this->required_column();
113 $query = Illuminate_Builder::get_builder()->selectRaw("COUNT(DISTINCT {$column}) AS total_table_rows")->from("{$sessions_table} AS sessions")->join("{$views_table} AS views", function (JoinClause $join) {
114 $join->on('sessions.session_id', '=', 'views.session_id');
115 })->tap(Query_Taps::tap_authored_content_check(\true))->when(!\is_null($this->rows), function (Builder $query) {
116 $this->rows->attach_filters($query);
117 })->whereBetween('sessions.created_at', [$this->date_range->iso_start(), $this->date_range->iso_end()])->whereBetween('views.viewed_at', [$this->date_range->iso_start(), $this->date_range->iso_end()]);
118 return $query->value('total_table_rows');
119 }
120 /**
121 * Define which id column to use to count up the total table rows. This is only required
122 * for classes that don't have a required column and don't override required_column
123 *
124 * @return string|null
125 */
126 protected function total_table_rows_column() : ?string
127 {
128 return null;
129 }
130 /**
131 * Statistics can require that a column exists in order to be included. As an example, geos
132 * requires visitors.country_code and campaigns requires sessions.campaign_id
133 *
134 * @return string|null
135 */
136 protected function required_column() : ?string
137 {
138 return null;
139 }
140 /**
141 * @return Statistic[]
142 */
143 private function make_statistic_instances() : array
144 {
145 $statistics = [$this->make_statistic(['id' => 'visitors', 'name' => \__('Visitors', 'independent-analytics'), 'plugin_group' => 'general', 'is_visible_in_dashboard_widget' => \true]), $this->make_statistic(['id' => 'views', 'name' => \__('Views', 'independent-analytics'), 'plugin_group' => 'general', 'is_visible_in_dashboard_widget' => \true]), $this->make_statistic(['id' => 'sessions', 'name' => \__('Sessions', 'independent-analytics'), 'plugin_group' => 'general']), $this->make_statistic(['id' => 'average_session_duration', 'name' => \__('Average Session Duration', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'time']), $this->make_statistic(['id' => 'bounce_rate', 'name' => \__('Bounce Rate', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'percent', 'is_growth_good' => \false, 'compute' => function (object $statistics) {
146 return Calculations::percentage($statistics->bounces, $statistics->sessions);
147 }]), $this->make_statistic(['id' => 'views_per_session', 'name' => \__('Views Per Session', 'independent-analytics'), 'plugin_group' => 'general', 'format' => 'decimal', 'compute' => function (object $statistics) {
148 return Calculations::divide($statistics->total_views, $statistics->sessions, 2);
149 }]), $this->make_statistic(['id' => 'wc_orders', 'name' => \__('Orders', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon()]), $this->make_statistic(['id' => 'wc_gross_sales', 'name' => \__('Gross Sales', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_refunds', 'name' => \__('Refunds', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon()]), $this->make_statistic(['id' => 'wc_refunded_amount', 'name' => \__('Refunded Amount', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_net_sales', 'name' => \__('Total Sales', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'wc_conversion_rate', 'name' => \__('Conversion Rate', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'percent']), $this->make_statistic(['id' => 'wc_earnings_per_visitor', 'name' => \__('Earnings Per Visitor', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'currency']), $this->make_statistic(['id' => 'wc_average_order_volume', 'name' => \__('Average Order Volume', 'independent-analytics'), 'plugin_group' => 'ecommerce', 'icon' => $this->get_ecommerce_icon(), 'format' => 'rounded-currency']), $this->make_statistic(['id' => 'form_submissions', 'name' => \__('Form Submissions', 'independent-analytics'), 'plugin_group' => 'forms']), $this->make_statistic(['id' => 'form_conversion_rate', 'name' => \__('Form Conversion Rate', 'independent-analytics'), 'plugin_group' => 'forms', 'format' => 'percent', 'compute' => function (object $statistics) {
150 return Calculations::percentage($statistics->form_submissions, $statistics->visitors, 2);
151 }])];
152 foreach (Form::get_forms() as $form) {
153 if (!$form->is_plugin_active()) {
154 continue;
155 }
156 $statistics[] = $this->make_statistic(['id' => 'form_submissions_for_' . $form->id(), 'name' => \sprintf(\_x('%s Submissions', 'Title of the contact form', 'independent-analytics'), $form->title()), 'plugin_group' => 'forms', 'is_subgroup_plugin_active' => $form->is_plugin_active(), 'plugin_group_header' => $form->plugin_name(), 'icon' => $form->icon()]);
157 $statistics[] = $this->make_statistic(['id' => 'form_conversion_rate_for_' . $form->id(), 'name' => \sprintf(\_x('%s Conversion Rate', 'Title of the contact form', 'independent-analytics'), $form->title()), 'plugin_group' => 'forms', 'is_subgroup_plugin_active' => $form->is_plugin_active(), 'plugin_group_header' => $form->plugin_name(), 'icon' => $form->icon(), 'format' => 'percent', 'compute' => function (object $statistics) use($form) {
158 $form_submission_id = 'form_submissions_for_' . $form->id();
159 return Calculations::percentage($statistics->{$form_submission_id}, $statistics->visitors, 2);
160 }]);
161 }
162 return $statistics;
163 }
164 private function make_statistic(array $attributes) : \IAWP\Statistics\Statistic
165 {
166 $statistic_id = $attributes['id'];
167 if (!\array_key_exists('compute', $attributes)) {
168 $attributes['compute'] = function ($statistics, $statistic_id) {
169 return $statistics->{$statistic_id};
170 };
171 }
172 $attributes['statistic'] = $attributes['compute']($this->statistics, $statistic_id);
173 $attributes['previous_period_statistic'] = $attributes['compute']($this->previous_period_statistics, $statistic_id);
174 $attributes['statistic_over_time'] = $this->fill_in_partial_day_range($this->statistics_grouped_by_date_interval, $attributes);
175 $attributes['unfiltered_statistic'] = $this->has_filters() ? $attributes['compute']($this->unfiltered_statistics, $statistic_id) : null;
176 return new \IAWP\Statistics\Statistic($attributes);
177 }
178 private function query(Date_Range $range, ?Rows $rows = null, bool $is_grouped_by_date_interval = \false)
179 {
180 $utc_offset = Timezone::utc_offset();
181 $site_offset = Timezone::site_offset();
182 $sessions_table = Query::get_table_name(Query::SESSIONS);
183 $views_table = Query::get_table_name(Query::VIEWS);
184 $orders_table = Query::get_table_name(Query::ORDERS);
185 $form_submissions_table = Query::get_table_name(Query::FORM_SUBMISSIONS);
186 $form_submissions_query = Illuminate_Builder::get_builder()->select(['form_id', 'session_id'])->selectRaw('COUNT(*) AS form_submissions')->from($form_submissions_table, 'form_submissions')->whereBetween('created_at', [$range->iso_start(), $range->iso_end()])->groupBy(['form_id', 'session_id']);
187 $session_statistics = Illuminate_Builder::get_builder();
188 $session_statistics->select('sessions.session_id')->selectRaw('COUNT(DISTINCT views.id) AS views')->selectRaw('COUNT(DISTINCT orders.order_id) AS orders')->selectRaw('IFNULL(CAST(SUM(orders.total) AS UNSIGNED), 0) AS gross_sales')->selectRaw('IFNULL(CAST(SUM(orders.total_refunded) AS UNSIGNED), 0) AS total_refunded')->selectRaw('IFNULL(CAST(SUM(orders.total_refunds) AS UNSIGNED), 0) AS total_refunds')->selectRaw('IFNULL(CAST(SUM(orders.total - orders.total_refunded) AS UNSIGNED), 0) AS net_sales')->from("{$sessions_table} AS sessions")->join("{$views_table} AS views", function (JoinClause $join) {
189 $join->on('sessions.session_id', '=', 'views.session_id');
190 })->leftJoin("{$orders_table} AS orders", function (JoinClause $join) {
191 $join->on('views.id', '=', 'orders.initial_view_id')->where('orders.is_included_in_analytics', '=', \true);
192 })->tap(Query_Taps::tap_authored_content_check(\true))->when(!\is_null($rows), function (Builder $query) use($rows) {
193 $rows->attach_filters($query);
194 })->whereBetween('sessions.created_at', [$range->iso_start(), $range->iso_end()])->whereBetween('views.viewed_at', [$range->iso_start(), $range->iso_end()])->groupBy('sessions.session_id')->when(!\is_null($this->required_column()), function (Builder $query) {
195 $query->whereNotNull($this->required_column());
196 });
197 $statistics = Illuminate_Builder::get_builder();
198 $statistics->selectRaw('IFNULL(CAST(SUM(sessions.total_views) AS UNSIGNED), 0) AS total_views')->selectRaw('IFNULL(CAST(SUM(session_statistics.views) AS UNSIGNED), 0) AS views')->selectRaw('COUNT(DISTINCT sessions.visitor_id) AS visitors')->selectRaw('COUNT(DISTINCT sessions.session_id) AS sessions')->selectRaw('IFNULL(CAST(AVG(TIMESTAMPDIFF(SECOND, sessions.created_at, sessions.ended_at)) AS UNSIGNED), 0) AS average_session_duration')->selectRaw('COUNT(DISTINCT IF(sessions.final_view_id IS NULL, sessions.session_id, NULL)) AS bounces')->selectRaw('IFNULL(CAST(SUM(session_statistics.orders) AS UNSIGNED), 0) AS wc_orders')->selectRaw('IFNULL(CAST(SUM(session_statistics.gross_sales) AS UNSIGNED), 0) AS wc_gross_sales')->selectRaw('IFNULL(CAST(SUM(session_statistics.total_refunds) AS UNSIGNED), 0) AS wc_refunds')->selectRaw('IFNULL(CAST(SUM(session_statistics.total_refunded) AS UNSIGNED), 0) AS wc_refunded_amount')->selectRaw('IFNULL(CAST(SUM(session_statistics.net_sales) AS UNSIGNED), 0) AS wc_net_sales')->selectRaw('IFNULL(SUM(form_submissions.form_submissions), 0) AS form_submissions')->tap(function (Builder $query) {
199 foreach (Form::get_forms() as $form) {
200 $query->selectRaw('IFNULL(SUM(IF(form_submissions.form_id = ?, form_submissions.form_submissions, 0)), 0) AS ' . $form->submissions_column(), [$form->id()]);
201 }
202 })->from("{$sessions_table} AS sessions")->joinSub($session_statistics, 'session_statistics', function (JoinClause $join) {
203 $join->on('sessions.session_id', '=', 'session_statistics.session_id');
204 })->leftJoinSub($form_submissions_query, 'form_submissions', 'sessions.session_id', '=', 'form_submissions.session_id')->whereBetween('sessions.created_at', [$range->iso_start(), $range->iso_end()])->when($is_grouped_by_date_interval, function (Builder $query) use($utc_offset, $site_offset) {
205 if ($this->chart_interval->id() === 'daily') {
206 $query->selectRaw("DATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) AS date");
207 } elseif ($this->chart_interval->id() === 'monthly') {
208 $query->selectRaw("DATE_FORMAT(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), '%Y-%m-01 00:00:00') AS date");
209 } elseif ($this->chart_interval->id() === 'weekly') {
210 $day_of_week = \IAWPSCOPED\iawp()->get_option('iawp_dow', 0) + 1;
211 $query->selectRaw("\n IF (\n DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week} < 0,\n DATE_FORMAT(SUBDATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week} + 7), '%Y-%m-%d 00:00:00'),\n DATE_FORMAT(SUBDATE(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), DAYOFWEEK(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}')) - {$day_of_week}), '%Y-%m-%d 00:00:00')\n ) AS date\n ");
212 } else {
213 $query->selectRaw("DATE_FORMAT(CONVERT_TZ(sessions.created_at, '{$utc_offset}', '{$site_offset}'), '%Y-%m-%d %H:00:00') AS date");
214 }
215 $query->groupByRaw("date");
216 });
217 $outer_query = Illuminate_Builder::get_builder()->selectRaw('statistics.*')->selectRaw('IF(statistics.visitors = 0, 0, (statistics.wc_orders / statistics.visitors) * 100) AS wc_conversion_rate')->selectRaw('IF(statistics.visitors = 0, 0, (statistics.wc_gross_sales - statistics.wc_refunded_amount) / visitors) AS wc_earnings_per_visitor')->selectRaw('IF(statistics.wc_orders = 0, 0, ROUND(CAST(statistics.wc_gross_sales / statistics.wc_orders AS DECIMAL(10, 2)))) AS wc_average_order_volume')->fromSub($statistics, 'statistics');
218 $results = \array_map(function (object $statistic) : object {
219 return $this->clean_up_raw_statistic_row($statistic);
220 }, $outer_query->get()->all());
221 if (!$is_grouped_by_date_interval) {
222 return $results[0];
223 }
224 return $results;
225 }
226 private function clean_up_raw_statistic_row(object $statistic) : object
227 {
228 $statistic->wc_gross_sales = \intval($statistic->wc_gross_sales);
229 $statistic->wc_refunded_amount = \intval($statistic->wc_refunded_amount);
230 $statistic->wc_net_sales = \intval($statistic->wc_net_sales);
231 foreach ($statistic as $key => $value) {
232 if (Str::startsWith($key, 'form_submissions')) {
233 $statistic->{$key} = \intval($value);
234 }
235 }
236 return $statistic;
237 }
238 /**
239 * @param array $partial_day_range
240 * @param array $attributes
241 *
242 * @return array
243 */
244 private function fill_in_partial_day_range(array $partial_day_range, array $attributes) : array
245 {
246 $original_start = (clone $this->date_range->start())->setTimezone(Timezone::site_timezone());
247 $start = $this->chart_interval->calculate_start_of_interval_for($original_start);
248 $original_end = (clone $this->date_range->end())->setTimezone(Timezone::site_timezone());
249 $end = $this->chart_interval->calculate_start_of_interval_for($original_end);
250 $end->add(new DateInterval('PT1S'));
251 $date_range = new DatePeriod($start, $this->chart_interval->date_interval(), $end);
252 $filled_in_data = [];
253 foreach ($date_range as $date) {
254 // There is no 00:00:00 on 2024-03-31 as that's when Beirut switches off DST
255 if (Timezone::site_timezone()->getName() === 'Asia/Beirut' && $date->format('H:i:s') === "01:00:00") {
256 $date->setTime(0, 0, 0);
257 }
258 $stat = $this->get_statistic_for_date($partial_day_range, $date, $attributes);
259 $filled_in_data[] = [$date, $stat];
260 }
261 return $filled_in_data;
262 }
263 /**
264 * @param array $partial_day_range
265 * @param DateTime $datetime_to_match
266 * @param array $attributes
267 *
268 * @return float|int
269 */
270 private function get_statistic_for_date(array $partial_day_range, DateTime $datetime_to_match, array $attributes)
271 {
272 foreach ($partial_day_range as $day) {
273 $date = $day->date;
274 $value = $attributes['compute']($day, $attributes['id']);
275 try {
276 $datetime = new DateTime($date, Timezone::site_timezone());
277 } catch (Throwable $e) {
278 return 0;
279 }
280 // Intentionally using non-strict equality to see if two distinct DateTime objects represent the same time
281 if ($datetime == $datetime_to_match) {
282 if (\is_string($value)) {
283 if (\strpos($value, '.') !== \false) {
284 return \floatval($value);
285 } else {
286 return \intval($value);
287 }
288 }
289 return $value;
290 }
291 }
292 return 0;
293 }
294 private function get_ecommerce_icon() : ?string
295 {
296 if (\IAWPSCOPED\iawp()->is_woocommerce_support_enabled() && !\IAWPSCOPED\iawp()->is_surecart_support_enabled()) {
297 return 'woocommerce';
298 }
299 if (\IAWPSCOPED\iawp()->is_surecart_support_enabled() && !\IAWPSCOPED\iawp()->is_woocommerce_support_enabled()) {
300 return 'surecart';
301 }
302 return null;
303 }
304 }
305