diff --git a/config/global.ini.php b/config/global.ini.php index 552791aab71..ca995501a5d 100644 --- a/config/global.ini.php +++ b/config/global.ini.php @@ -636,6 +636,11 @@ ; maximum number of rows for the Products reports datatable_archiving_maximum_rows_products = 10000 +; maximum number of AI Assistants listed in Bot Tracking reports +datatable_archiving_maximum_rows_bots = 250 +; maximum number of page/document rows listed per AI Assistant in Bot Tracking reports +datatable_archiving_maximum_rows_subtable_bots = 250 + ; maximum number of rows for other tables (Providers, User settings configurations) datatable_archiving_maximum_rows_standard = 500 diff --git a/core/ArchiveProcessor.php b/core/ArchiveProcessor.php index ec2031f0e7d..25b3477861e 100644 --- a/core/ArchiveProcessor.php +++ b/core/ArchiveProcessor.php @@ -192,9 +192,8 @@ public function getLogAggregator() * @param array $columnsToRenameAfterAggregation Columns mapped to new names for columns that must change names * when summed because they cannot be summed, eg, * `array('nb_uniq_visitors' => 'sum_daily_nb_uniq_visitors')`. - * @param bool|array $countRowsRecursive if set to true, will calculate the recursive rows count for all record names - * which makes it slower. If you only need it for some records pass an array of - * recordNames that defines for which ones you need a recursive row count. + * @param string[]|bool $countRowsRecursive array of recordNames that defines for which ones you need a recursive row count, or true if it should be done for all + * @param string[] $countLeafRows array of recordNames that defines for which ones you need a leaf row count. * @return array Returns the row counts of each aggregated report before truncation, eg, * * array( @@ -213,7 +212,8 @@ public function aggregateDataTableRecords( $defaultColumnToSortByBeforeTruncation = null, &$columnsAggregationOperation = null, $columnsToRenameAfterAggregation = null, - $countRowsRecursive = true + $countRowsRecursive = true, + array $countLeafRows = [] ) { /** @var LoggerInterface $logger */ $logger = StaticContainer::get(LoggerInterface::class); @@ -239,6 +239,9 @@ public function aggregateDataTableRecords( if ($countRowsRecursive === true || (is_array($countRowsRecursive) && in_array($recordName, $countRowsRecursive))) { $nameToCount[$recordName]['recursive'] = $table->getRowsCountRecursive(); } + if (in_array($recordName, $countLeafRows)) { + $nameToCount[$recordName]['leafs'] = $table->getLeafRowsCount(); + } $columnToSortByBeforeTruncation = $defaultColumnToSortByBeforeTruncation; if (empty($columnToSortByBeforeTruncation)) { diff --git a/core/ArchiveProcessor/Record.php b/core/ArchiveProcessor/Record.php index b77dc444452..2c2af12ba81 100644 --- a/core/ArchiveProcessor/Record.php +++ b/core/ArchiveProcessor/Record.php @@ -58,6 +58,11 @@ class Record */ private $countOfRecordNameIsRecursive = false; + /** + * @var bool + */ + private $countOfRecordNameIsForLeafs = false; + /** * @var array|null */ @@ -200,6 +205,13 @@ public function setIsCountOfBlobRecordRows(string $dependentRecordName, bool $is return $this; } + public function setIsCountOfBlobRecordLeafRows(string $dependentRecordName): Record + { + $this->countOfRecordName = $dependentRecordName; + $this->countOfRecordNameIsForLeafs = true; + return $this; + } + /** * @return string|null */ @@ -216,6 +228,14 @@ public function getCountOfRecordNameIsRecursive(): bool return $this->countOfRecordNameIsRecursive; } + /** + * @return bool + */ + public function getCountOfRecordNameIsForLeafs(): bool + { + return $this->countOfRecordNameIsForLeafs; + } + /** * @param array|null $columnToRenameAfterAggregation * @return Record diff --git a/core/ArchiveProcessor/RecordBuilder.php b/core/ArchiveProcessor/RecordBuilder.php index 1fde59d7ecb..d017198df9b 100644 --- a/core/ArchiveProcessor/RecordBuilder.php +++ b/core/ArchiveProcessor/RecordBuilder.php @@ -20,12 +20,12 @@ abstract class RecordBuilder { /** - * @var int + * @var int|null */ protected $maxRowsInTable; /** - * @var int + * @var int|null */ protected $maxRowsInSubtable; @@ -40,7 +40,7 @@ abstract class RecordBuilder protected $columnAggregationOps; /** - * @var array|null + * @var array|null */ protected $columnToRenameAfterAggregation; @@ -49,6 +49,7 @@ abstract class RecordBuilder * @param int|null $maxRowsInSubtable * @param string|null $columnToSortByBeforeTruncation * @param array|null $columnAggregationOps + * @param array|null $columnToRenameAfterAggregation */ public function __construct( ?int $maxRowsInTable = null, @@ -190,15 +191,18 @@ public function buildForNonDayPeriod(ArchiveProcessor $archiveProcessor): void $columnToRenameAfterAggregation = $record->getColumnToRenameAfterAggregation() ?? $this->columnToRenameAfterAggregation; $columnAggregationOps = $record->getBlobColumnAggregationOps() ?? $this->columnAggregationOps; - // only do recursive row count if there is a numeric record that depends on it - $countRecursiveRows = false; + // only do recursive row counts if there is a numeric record that depends on it + $countRecursiveRows = $countLeafRows = []; foreach ($numericRecords as $numeric) { if ( $numeric->getCountOfRecordName() == $record->getName() - && $numeric->getCountOfRecordNameIsRecursive() ) { - $countRecursiveRows = true; - break; + if ($numeric->getCountOfRecordNameIsRecursive()) { + $countRecursiveRows[] = $numeric->getCountOfRecordName(); + } + if ($numeric->getCountOfRecordNameIsForLeafs()) { + $countLeafRows[] = $numeric->getCountOfRecordName(); + } } } @@ -209,7 +213,8 @@ public function buildForNonDayPeriod(ArchiveProcessor $archiveProcessor): void $columnToSortByBeforeTruncation, $columnAggregationOps, $columnToRenameAfterAggregation, - $countRecursiveRows + $countRecursiveRows, + $countLeafRows ); $aggregatedCounts = array_merge($aggregatedCounts, $counts); @@ -250,7 +255,9 @@ public function buildForNonDayPeriod(ArchiveProcessor $archiveProcessor): void $count = $aggregatedCounts[$dependentRecordName]; - if ($record->getCountOfRecordNameIsRecursive()) { + if ($record->getCountOfRecordNameIsForLeafs()) { + $recordCountMetricValues[$record->getName()] = $count['leafs']; + } elseif ($record->getCountOfRecordNameIsRecursive()) { $recordCountMetricValues[$record->getName()] = $count['recursive']; } else { $recordCountMetricValues[$record->getName()] = $count['level0']; diff --git a/core/DataTable.php b/core/DataTable.php index 51e2bddf047..5c4a4ba6631 100644 --- a/core/DataTable.php +++ b/core/DataTable.php @@ -1128,6 +1128,26 @@ public function getRowsCountRecursive() return $totalCount; } + /** + * Returns the number of leaf rows in the entire DataTable hierarchy. Only rows that do not contain a subtables are counted + * + * @return int + */ + public function getLeafRowsCount() + { + $totalCount = 0; + foreach ($this->rows as $row) { + $subTable = $row->getSubtable(); + if ($subTable) { + $totalCount += $subTable->getLeafRowsCount(); + } else { + $totalCount++; + } + } + + return $totalCount; + } + /** * Delete a column by name in every row. This change is NOT applied recursively to all * subtables. @@ -1839,10 +1859,10 @@ public function setMaximumAllowedRows($maximumAllowedRows) * created for path labels that cannot be found. * @param int $maxSubtableRows The maximum number of allowed rows in new subtables. New * subtables are only created if `$missingRowColumns` is provided. - * @return array First element is the found row or `false`. Second element is - * the number of path segments walked. If a row is found, this - * will be == to `count($path)`. Otherwise, it will be the index - * of the path segment that we could not find. + * @return array{0: false|Row, 1: int} First element is the found row or `false`. Second element is + * the number of path segments walked. If a row is found, this + * will be == to `count($path)`. Otherwise, it will be the index + * of the path segment that we could not find. */ public function walkPath($path, $missingRowColumns = false, $maxSubtableRows = 0) { diff --git a/plugins/AIAgents/lang/en.json b/plugins/AIAgents/lang/en.json index 1acac348aa8..b1b3558e99b 100644 --- a/plugins/AIAgents/lang/en.json +++ b/plugins/AIAgents/lang/en.json @@ -5,9 +5,6 @@ "AIAgentVisits": "AI Agent Visits", "AIAgentVisitsDocumentation": "This is an overview of AI Agent visits. AI agent traffic only includes visits where an AI opens your site in a real browser and behaves like a human visitor. It does not include background requests from ChatGPT or other AI tools that fetch your pages without using a browser.", "AIAgentsOverviewSubcategoryDescription": "Review how AI agents and human visitors engage with your site at a glance. This overview surfaces combined metrics and trends so you can quickly spot changes before exploring detailed reports.", - "AIAssistantsOverviewHelp1": "The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.", - "AIAssistantsOverviewHelp2": "It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.", - "AIAssistantsOverviewHelp3": "Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.", "ColumnAIAgentActions": "Actions by AI Agent Visits", "ColumnAIAgentAverageVisitDuration": "Avg. Duration of an AI Agent Visit (in sec)", "ColumnAIAgentAvgActionsPerVisit": "Avg. Actions per AI Agent Visit", diff --git a/plugins/AIAgents/tests/UI/expected-ui-screenshots/AIAgents_menu.png b/plugins/AIAgents/tests/UI/expected-ui-screenshots/AIAgents_menu.png index 0ef57c5da9c..840b00a6257 100644 Binary files a/plugins/AIAgents/tests/UI/expected-ui-screenshots/AIAgents_menu.png and b/plugins/AIAgents/tests/UI/expected-ui-screenshots/AIAgents_menu.png differ diff --git a/plugins/BotTracking/API.php b/plugins/BotTracking/API.php new file mode 100644 index 00000000000..aa742f399df --- /dev/null +++ b/plugins/BotTracking/API.php @@ -0,0 +1,119 @@ +getDataTableFromNumeric($metrics); + + $this->filterColumns($dataTable, $columns); + + return $dataTable; + } + + /** + * Returns a report about AI assistants crawling your site and how many hits each one generates. Depending on the provided secondary dimension + * the subtable will either contain all requested page urls or document urls. + * + * @param string|int|int[] $idSite + * @param null|'pages'|'documents' $secondaryDimension can be either `pages` (default) or `documents` + * @return DataTable|DataTable\Map + */ + public function getAIAssistantRequests($idSite, string $period, string $date, bool $expanded = false, bool $flat = false, ?string $secondaryDimension = null): DataTableInterface + { + Piwik::checkUserHasViewAccess($idSite); + + $archiveName = Archiver::AI_ASSISTANTS_PAGES_RECORD; + + if ($secondaryDimension === 'documents') { + $archiveName = Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD; + } + + $dataTable = Archive::createDataTableFromArchive($archiveName, $idSite, $period, $date, '', $expanded, $flat); + + // When flattening a report, remove all main table rows, where no subtable exists + if ($flat) { + $dataTable->filter(function (DataTable $table) { + foreach ($table->getRows() as $key => $row) { + if (!$row->getIdSubDataTable()) { + $table->deleteRow($key); + } + } + }); + } + + return $dataTable; + } + + /** + * @param string|int|int[] $idSite + * @return DataTable|DataTable\Map + */ + public function getPageUrlsForAIAssistant($idSite, string $period, string $date, int $idSubtable): DataTableInterface + { + Piwik::checkUserHasViewAccess($idSite); + + return Archive::createDataTableFromArchive(Archiver::AI_ASSISTANTS_PAGES_RECORD, $idSite, $period, $date, '', false, false, $idSubtable); + } + + /** + * @param string|int|int[] $idSite + * @return DataTable|DataTable\Map + */ + public function getDocumentUrlsForAIAssistant($idSite, string $period, string $date, int $idSubtable): DataTableInterface + { + Piwik::checkUserHasViewAccess($idSite); + + return Archive::createDataTableFromArchive(Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD, $idSite, $period, $date, '', false, false, $idSubtable); + } + + /** + * @param null|string|string[] $columns + */ + private function filterColumns(DataTableInterface $table, $columns): void + { + if (empty($columns)) { + return; + } + + $columnsToKeep = Piwik::getArrayFromApiParameter($columns); + if (empty($columnsToKeep)) { + return; + } + + $table->filter(ColumnDelete::class, [[], $columnsToKeep]); + } +} diff --git a/plugins/BotTracking/Archiver.php b/plugins/BotTracking/Archiver.php new file mode 100644 index 00000000000..4e40808157a --- /dev/null +++ b/plugins/BotTracking/Archiver.php @@ -0,0 +1,25 @@ + */ private $aiAssistantPatterns = [ - 'ChatGPT-User' => 'ai_assistant', - 'MistralAI-User' => 'ai_assistant', - 'Gemini-Deep-Research' => 'ai_assistant', - 'Claude-User' => 'ai_assistant', - 'Perplexity-User' => 'ai_assistant', - 'Google-NotebookLM' => 'ai_assistant', - 'Devin' => 'ai_assistant', + 'ChatGPT-User' => self::BOT_TYPE_AI_ASSISTANT, + 'MistralAI-User' => self::BOT_TYPE_AI_ASSISTANT, + 'Gemini-Deep-Research' => self::BOT_TYPE_AI_ASSISTANT, + 'Claude-User' => self::BOT_TYPE_AI_ASSISTANT, + 'Perplexity-User' => self::BOT_TYPE_AI_ASSISTANT, + 'Google-NotebookLM' => self::BOT_TYPE_AI_ASSISTANT, + 'Devin' => self::BOT_TYPE_AI_ASSISTANT, ]; public function __construct(string $userAgent) diff --git a/plugins/BotTracking/BotTracking.php b/plugins/BotTracking/BotTracking.php index 84f31bbcbdc..48bd0aa8ab1 100644 --- a/plugins/BotTracking/BotTracking.php +++ b/plugins/BotTracking/BotTracking.php @@ -15,6 +15,7 @@ use Piwik\Plugin; use Piwik\Plugins\BotTracking\Dao\BotRequestsDao; use Piwik\Plugins\SitesManager\API; +use Piwik\Plugins\BotTracking\Metrics as BotMetrics; use Piwik\Tracker\Request; /** @@ -40,9 +41,13 @@ public function isTrackerPlugin() public function registerEvents(): array { return [ - 'PrivacyManager.deleteLogsOlderThan' => 'deleteLogsOlderThan', - 'PrivacyManager.deleteDataSubjectsForDeletedSites' => 'deleteDataSubjectsForDeletedSites', - 'Tracker.isBotRequest' => 'isBotRequest', + 'PrivacyManager.deleteLogsOlderThan' => 'deleteLogsOlderThan', + 'PrivacyManager.deleteDataSubjectsForDeletedSites' => 'deleteDataSubjectsForDeletedSites', + 'Tracker.isBotRequest' => 'isBotRequest', + 'Metrics.getEvolutionUnit' => 'getEvolutionUnit', + 'Metrics.getDefaultMetricTranslations' => 'addMetricTranslations', + 'Metrics.getDefaultMetricDocumentationTranslations' => 'addMetricDocumentationTranslations', + 'Metrics.getDefaultMetricSemanticTypes' => 'addMetricSemanticTypes', ]; } @@ -100,4 +105,35 @@ public function isBotRequest(bool &$isBot, Request $request): void $isBot = true; } } + + public function getEvolutionUnit(?string &$unit, string $column): void + { + if ($column === Metrics::METRIC_AI_ASSISTANTS_CLICK_THROUGH_RATE) { + $unit = '%'; + } + } + + /** + * @param array $translations + */ + public function addMetricTranslations(array &$translations): void + { + $translations = array_merge($translations, BotMetrics::getMetricTranslations()); + } + + /** + * @param array $translations + */ + public function addMetricDocumentationTranslations(array &$translations): void + { + $translations = array_merge($translations, BotMetrics::getMetricDocumentation()); + } + + /** + * @param array $types + */ + public function addMetricSemanticTypes(array &$types): void + { + $types = array_merge($types, BotMetrics::getMetricSemanticTypes()); + } } diff --git a/plugins/BotTracking/Categories/AIBotsOverviewSubcategory.php b/plugins/BotTracking/Categories/AIBotsOverviewSubcategory.php new file mode 100644 index 00000000000..15311e40633 --- /dev/null +++ b/plugins/BotTracking/Categories/AIBotsOverviewSubcategory.php @@ -0,0 +1,32 @@ +%1$s

%2$s

%3$s

', + Piwik::translate('BotTracking_AIAssistantsOverviewHelp1'), + Piwik::translate('BotTracking_AIAssistantsOverviewHelp2'), + Piwik::translate('BotTracking_AIAssistantsOverviewHelp3') + ); + } +} diff --git a/plugins/BotTracking/Columns/AIAssistantName.php b/plugins/BotTracking/Columns/AIAssistantName.php new file mode 100644 index 00000000000..d5e87bed7aa --- /dev/null +++ b/plugins/BotTracking/Columns/AIAssistantName.php @@ -0,0 +1,20 @@ +getPrettyNumber($value); + } + + public function getSemanticType(): ?string + { + return Dimension::TYPE_NUMBER; + } +} diff --git a/plugins/BotTracking/Columns/Metrics/ClickThroughRate.php b/plugins/BotTracking/Columns/Metrics/ClickThroughRate.php new file mode 100644 index 00000000000..68d2de5e7d6 --- /dev/null +++ b/plugins/BotTracking/Columns/Metrics/ClickThroughRate.php @@ -0,0 +1,67 @@ +getMetric($row, Metrics::METRIC_AI_ASSISTANTS_REQUESTS); + $visits = (int)$this->getMetric($row, Metrics::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS); + + return Piwik::getQuotientSafe($visits, $requests, 4); + } + + /** + * @param number $value + * @return string + */ + public function format($value, Formatter $formatter) + { + return $formatter->getPrettyPercentFromQuotient($value); + } + + public function getSemanticType(): ?string + { + return Dimension::TYPE_PERCENT; + } +} diff --git a/plugins/BotTracking/Columns/Metrics/DocumentRequests.php b/plugins/BotTracking/Columns/Metrics/DocumentRequests.php new file mode 100644 index 00000000000..62b1106b6d5 --- /dev/null +++ b/plugins/BotTracking/Columns/Metrics/DocumentRequests.php @@ -0,0 +1,50 @@ +getPrettyNumber($value); + } + + public function getSemanticType(): ?string + { + return Dimension::TYPE_NUMBER; + } +} diff --git a/plugins/BotTracking/Columns/Metrics/PageRequests.php b/plugins/BotTracking/Columns/Metrics/PageRequests.php new file mode 100644 index 00000000000..982bf374b44 --- /dev/null +++ b/plugins/BotTracking/Columns/Metrics/PageRequests.php @@ -0,0 +1,50 @@ +getPrettyNumber($value); + } + + public function getSemanticType(): ?string + { + return Dimension::TYPE_NUMBER; + } +} diff --git a/plugins/BotTracking/Columns/Metrics/Requests.php b/plugins/BotTracking/Columns/Metrics/Requests.php new file mode 100644 index 00000000000..c333a75d0ce --- /dev/null +++ b/plugins/BotTracking/Columns/Metrics/Requests.php @@ -0,0 +1,50 @@ +getPrettyNumber($value); + } + + public function getSemanticType(): ?string + { + return Dimension::TYPE_NUMBER; + } +} diff --git a/plugins/BotTracking/Columns/PageUrl.php b/plugins/BotTracking/Columns/PageUrl.php new file mode 100644 index 00000000000..cf6515736e8 --- /dev/null +++ b/plugins/BotTracking/Columns/PageUrl.php @@ -0,0 +1,20 @@ +checkSitePermission(); + + $columns = []; + + $columnsFromRequest = Request::fromRequest()->getParameter('columns', ''); + if (!empty($columnsFromRequest) && (is_array($columnsFromRequest) || is_string($columnsFromRequest))) { + $columns = Piwik::getArrayFromApiParameter($columnsFromRequest); + } + + $documentation = Piwik::translate('BotTracking_BotsOverTimeReportDocumentation') . '

'; + $translations = Metrics::getMetricTranslations(); + $docs = Metrics::getMetricDocumentation(); + foreach (Metrics::getSparklineMetricOrder() as $metric) { + if (empty($translations[$metric]) || empty($docs[$metric])) { + continue; + } + + $documentation .= sprintf('%s: %s
', $translations[$metric], $docs[$metric]); + } + + $metrics = Metrics::getSparklineMetricOrder(); + + if (Request::fromRequest()->getStringParameter('period', '') !== 'day') { + $metrics = array_filter($metrics, function ($metric) { + return !in_array($metric, [Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS]); + }); + } + + $view = $this->getLastUnitGraphAcrossPlugins( + $this->pluginName, + __FUNCTION__, + $columns, + $metrics, + $documentation, + 'BotTracking.get' + ); + + if (empty($view->config->columns_to_display)) { + $view->config->columns_to_display = [Metrics::METRIC_AI_ASSISTANTS_REQUESTS]; + } + + return $this->renderView($view); + } +} diff --git a/plugins/BotTracking/Metrics.php b/plugins/BotTracking/Metrics.php new file mode 100644 index 00000000000..a39b0c542dc --- /dev/null +++ b/plugins/BotTracking/Metrics.php @@ -0,0 +1,139 @@ + + */ + public static function getMetricTranslations(): array + { + return [ + self::METRIC_AI_ASSISTANTS_REQUESTS => Piwik::translate('BotTracking_ColumnRequests'), + self::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS => Piwik::translate('BotTracking_ColumnAcquiredVisits'), + self::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS => Piwik::translate('BotTracking_ColumnUniquePageUrls'), + self::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS => Piwik::translate('BotTracking_ColumnUniqueDocumentUrls'), + self::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS => Piwik::translate('BotTracking_ColumnUniqueAiChatbots'), + self::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS => Piwik::translate('BotTracking_ColumnNotFoundRequests'), + self::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS => Piwik::translate('BotTracking_ColumnServerErrorRequests'), + self::METRIC_AI_ASSISTANTS_CLICK_THROUGH_RATE => Piwik::translate('BotTracking_ColumnClickThroughRate'), + ]; + } + + /** + * @return array + */ + public static function getMetricDocumentation(): array + { + return [ + self::METRIC_AI_ASSISTANTS_REQUESTS => Piwik::translate('BotTracking_ColumnRequestsDocumentation'), + self::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS => Piwik::translate('BotTracking_ColumnAcquiredVisitsDocumentation'), + self::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS => Piwik::translate('BotTracking_ColumnUniquePageUrlsDocumentation'), + self::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS => Piwik::translate('BotTracking_ColumnUniqueDocumentUrlsDocumentation'), + self::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS => Piwik::translate('BotTracking_ColumnUniqueAiChatbotsDocumentation'), + self::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS => Piwik::translate('BotTracking_ColumnNotFoundRequestsDocumentation'), + self::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS => Piwik::translate('BotTracking_ColumnServerErrorRequestsDocumentation'), + self::METRIC_AI_ASSISTANTS_CLICK_THROUGH_RATE => Piwik::translate('BotTracking_ColumnClickThroughRateDocumentation'), + ]; + } + + /** + * @return array + */ + public static function getMetricSemanticTypes(): array + { + return [ + self::METRIC_AI_ASSISTANTS_REQUESTS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS => Dimension::TYPE_NUMBER, + self::METRIC_AI_ASSISTANTS_CLICK_THROUGH_RATE => Dimension::TYPE_PERCENT, + ]; + } + + /** + * Map of glossary entries (translation key => documentation key). + * + * @return array + */ + public static function getGlossaryEntries(): array + { + return [ + self::METRIC_AI_ASSISTANTS_REQUESTS => 'BotTracking_ColumnRequestsDocumentation', + self::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS => 'BotTracking_ColumnAcquiredVisitsDocumentation', + self::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS => 'BotTracking_ColumnUniquePageUrlsDocumentation', + self::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS => 'BotTracking_ColumnUniqueDocumentUrlsDocumentation', + self::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS => 'BotTracking_ColumnUniqueAiChatbotsDocumentation', + self::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS => 'BotTracking_ColumnNotFoundRequestsDocumentation', + self::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS => 'BotTracking_ColumnServerErrorRequestsDocumentation', + self::METRIC_AI_ASSISTANTS_CLICK_THROUGH_RATE => 'BotTracking_ColumnClickThroughRateDocumentation', + ]; + } +} diff --git a/plugins/BotTracking/RecordBuilders/AIAssistantReports.php b/plugins/BotTracking/RecordBuilders/AIAssistantReports.php new file mode 100644 index 00000000000..58ed7e4cf11 --- /dev/null +++ b/plugins/BotTracking/RecordBuilders/AIAssistantReports.php @@ -0,0 +1,372 @@ + + */ + private const ASSISTANT_MAPPING = [ + 'ChatGPT-User' => 'ChatGPT', + 'MistralAI-User' => 'Le Chat', + 'Gemini-Deep-Research' => 'Gemini', + 'Claude-User' => 'Claude', + 'Perplexity-User' => 'Perplexity', + 'Google-NotebookLM' => 'NotebookLM', + 'Devin' => '', + ]; + + /** + * @var int + */ + private $rankingQueryLimit; + + public function __construct() + { + parent::__construct(); + + $this->columnToSortByBeforeTruncation = Metrics::COLUMN_REQUESTS; + $this->maxRowsInTable = (int)GeneralConfig::getConfigValue('datatable_archiving_maximum_rows_bots'); + $this->maxRowsInSubtable = (int)GeneralConfig::getConfigValue('datatable_archiving_maximum_rows_subtable_bots'); + $this->rankingQueryLimit = $this->getRankingQueryLimit(); + $this->columnAggregationOps = [ + Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS => 'skip', + Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS => 'skip', + ]; + } + + public function getRecordMetadata(ArchiveProcessor $archiveProcessor): array + { + return [ + Record::make(Record::TYPE_BLOB, Archiver::AI_ASSISTANTS_PAGES_RECORD), + Record::make(Record::TYPE_BLOB, Archiver::AI_ASSISTANTS_REQUESTED_DOCUMENTS_RECORD), + Record::make(Record::TYPE_BLOB, Archiver::AI_ASSISTANTS_REQUESTED_PAGES_RECORD), + Record::make(Record::TYPE_BLOB, Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS) + ->setIsCountOfBlobRecordRows(Archiver::AI_ASSISTANTS_PAGES_RECORD), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_REQUESTS), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS), + Record::make(Record::TYPE_NUMERIC, Metrics::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS), + ]; + } + + public function isEnabled(ArchiveProcessor $archiveProcessor): bool + { + // don't process reports for any segment + return $archiveProcessor->getParams()->getSegment()->isEmpty(); + } + + protected function aggregate(ArchiveProcessor $archiveProcessor): array + { + $tables = [ + Archiver::AI_ASSISTANTS_PAGES_RECORD => new DataTable(), + Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD => new DataTable(), + Archiver::AI_ASSISTANTS_REQUESTED_PAGES_RECORD => new DataTable(), + Archiver::AI_ASSISTANTS_REQUESTED_DOCUMENTS_RECORD => new DataTable(), + ]; + + $this->populateTables($archiveProcessor, $tables); + $this->populateNumerics($archiveProcessor, $tables); + + return $tables; + } + + /** + * @param array $tables + */ + private function populateTables(ArchiveProcessor $archiveProcessor, array &$tables): void + { + $logAggregator = $archiveProcessor->getLogAggregator(); + $visits = $this->queryAcquiredVisitsByAIAssistant($logAggregator); + + $this->populateAssistantTableForActionType($tables, Action::TYPE_PAGE_URL, $logAggregator, $visits); + $this->populateAssistantTableForActionType($tables, Action::TYPE_DOWNLOAD, $logAggregator, $visits); + + $this->populateRequestTableForActionType($tables[Archiver::AI_ASSISTANTS_REQUESTED_PAGES_RECORD], Action::TYPE_PAGE_URL, $logAggregator); + $this->populateRequestTableForActionType($tables[Archiver::AI_ASSISTANTS_REQUESTED_DOCUMENTS_RECORD], Action::TYPE_DOWNLOAD, $logAggregator); + } + + /** + * @return array + */ + private function queryAcquiredVisitsByAIAssistant(LogAggregator $logAggregator): array + { + $where = $logAggregator->getWhereStatement('log_visit', 'visit_last_action_time'); + $bindBase = $logAggregator->getGeneralQueryBindParams(); + + $sql = sprintf( + "SELECT `referer_name`, COUNT(*) AS `visits` + FROM %s AS `log_visit` + WHERE `referer_type` = %d + AND `referer_name` <> '' + AND %s + GROUP BY `referer_name`", + Common::prefixTable('log_visit'), + Common::REFERRER_TYPE_AI_ASSISTANT, + $where + ); + + $stmt = Db::query($sql, $bindBase); + $result = []; + + while ($row = $stmt->fetch()) { + /** + * @var array{visits: string|int, referer_name: string} $row + */ + if (in_array($row['referer_name'], self::ASSISTANT_MAPPING)) { + $key = (string)array_search($row['referer_name'], self::ASSISTANT_MAPPING); + $result[$key] = (int)$row['visits']; + } + } + + return $result; + } + + /** + * @param array $tables + * @param array $visits + * @return void + */ + private function populateAssistantTableForActionType(array $tables, int $actionType, LogAggregator $logAggregator, array $visits): void + { + $resultSet = $this->queryBotRequests($logAggregator, $actionType); + + while ($row = $resultSet->fetch()) { + /** + * @var array{requests: int, bot_name: ?string, url: ?string} $row + */ + $label = $row['bot_name']; + $url = $row['url']; + + if ($label === null) { + // top-level rollup result + continue; + } + + if ($url === null) { + // second-level rollup result + $metrics = [ + Metrics::COLUMN_REQUESTS => $row['requests'], + Metrics::COLUMN_DOCUMENT_REQUESTS => $actionType === Action::TYPE_DOWNLOAD ? $row['requests'] : 0, + Metrics::COLUMN_PAGE_REQUESTS => $actionType === Action::TYPE_PAGE_URL ? $row['requests'] : 0, + Metrics::COLUMN_ACQUIRED_VISITS => $visits[$label] ?? 0, + ]; + + $tables[Archiver::AI_ASSISTANTS_PAGES_RECORD]->sumRowWithLabel($label, $metrics, [Metrics::COLUMN_ACQUIRED_VISITS => 'max']); + $tables[Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD]->sumRowWithLabel($label, $metrics, [Metrics::COLUMN_ACQUIRED_VISITS => 'max']); + continue; + } + + + $table = $tables[Archiver::AI_ASSISTANTS_PAGES_RECORD]; + + if ($actionType === Action::TYPE_DOWNLOAD) { + $table = $tables[Archiver::AI_ASSISTANTS_DOCUMENTS_RECORD]; + } + + $tableRow = $table->getRowFromLabel($label); + + if (false === $tableRow) { + // non-rollup row but rollup row is missing + // should not happen, but don't break + continue; + } + + if ( + $url === RankingQuery::LABEL_SUMMARY_ROW + && !$tableRow->isSubtableLoaded() + ) { + // skip creating the subtable if: + // - we are using rollups + // - the only row would be "Others" + continue; + } + + $normalized = PageUrl::normalizeUrl($url); + $url = $normalized['url']; + + $tableRow->sumRowWithLabelToSubtable($url, [ + Metrics::COLUMN_REQUESTS => $row['requests'], + ]); + } + } + + private function queryBotRequests(LogAggregator $logAggregator, int $actionType) + { + $where = $logAggregator->getWhereStatement('bot', 'server_time'); + + $sql = sprintf( + "SELECT * FROM (SELECT bot.bot_name, log_action.name AS url, COUNT(*) AS requests + FROM `%s` AS bot + INNER JOIN `%s` AS log_action ON log_action.idaction = bot.idaction_url + WHERE log_action.name IS NOT NULL + AND log_action.name <> '' + AND log_action.type = %d + AND bot.bot_type = ? + AND %s + GROUP BY bot.bot_name, url WITH ROLLUP) AS rollupQuery + ORDER BY requests DESC, bot_name, url", + BotRequestsDao::getPrefixedTableName(), + Common::prefixTable('log_action'), + $actionType, + $where + ); + + if ($this->rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($this->rankingQueryLimit); + $rankingQuery->addLabelColumn(['bot_name', 'url']); + $rankingQuery->addColumn('requests', 'sum'); + $sql = $rankingQuery->generateRankingQuery($sql, true); + } + + return Db::query($sql, array_merge([BotDetector::BOT_TYPE_AI_ASSISTANT], $logAggregator->getGeneralQueryBindParams())); + } + + private function getRankingQueryLimit(): int + { + $maxRowsInTable = (int)$this->maxRowsInTable; + $maxRowsInSubtable = (int)$this->maxRowsInSubtable; + + $configLimit = (int)GeneralConfig::getConfigValue('archiving_ranking_query_row_limit'); + $configLimit = max($configLimit, 10 * $maxRowsInTable); + + if ($configLimit === 0) { + return 0; + } + + return max($configLimit, $maxRowsInTable, $maxRowsInSubtable); + } + + public function populateRequestTableForActionType(DataTable $table, int $actionType, LogAggregator $logAggregator): void + { + $where = $logAggregator->getWhereStatement('bot', 'server_time'); + $bindBase = $logAggregator->getGeneralQueryBindParams(); + + $sql = sprintf( + "SELECT log_action.name AS url, log_action.url_prefix, COUNT(*) AS requests + FROM `%s` AS bot + INNER JOIN `%s` AS log_action ON log_action.idaction = bot.idaction_url + WHERE log_action.name IS NOT NULL + AND log_action.name <> '' + AND log_action.type = %d + AND bot.bot_type = ? + AND %s + GROUP BY log_action.name + ORDER BY requests DESC, url", + BotRequestsDao::getPrefixedTableName(), + Common::prefixTable('log_action'), + $actionType, + $where + ); + + if ($this->rankingQueryLimit > 0) { + $rankingQuery = new RankingQuery($this->rankingQueryLimit); + $rankingQuery->addLabelColumn(['url']); + $rankingQuery->addColumn('requests'); + $rankingQuery->addColumn('url_prefix', 'sum'); + $sql = $rankingQuery->generateRankingQuery($sql, true); + } + + $resultSet = Db::query($sql, array_merge([BotDetector::BOT_TYPE_AI_ASSISTANT], $bindBase)); + + while ($record = $resultSet->fetch()) { + /** + * @var array{requests: int, url: string, url_prefix: ?int} $record + */ + $path = ArchivingHelper::getActionExplodedNames($record['url'], $actionType, $record['url_prefix']); + [$row, $level] = $table->walkPath($path, [Metrics::COLUMN_REQUESTS => 0], $this->maxRowsInSubtable); + + if ($row) { + $row->setColumn(Metrics::COLUMN_REQUESTS, $record['requests']); + } + } + } + + /** + * @param array $tables + */ + private function populateNumerics(ArchiveProcessor $archiveProcessor, array &$tables): void + { + $logAggregator = $archiveProcessor->getLogAggregator(); + + $table = BotRequestsDao::getPrefixedTableName(); + $visitTable = Common::prefixTable('log_visit'); + $actionTable = Common::prefixTable('log_action'); + + $where = $logAggregator->getWhereStatement('bot', 'server_time'); + + $sql = <<getGeneralQueryBindParams()); + + $row = Db::fetchRow($sql, $bind) ?: []; + + $visitBind = [ + Common::REFERRER_TYPE_AI_ASSISTANT, + ]; + $visitBind = array_merge($visitBind, $logAggregator->getGeneralQueryBindParams()); + + $where = $logAggregator->getWhereStatement('log_visit', 'visit_last_action_time'); + + $visitsSql = sprintf( + "SELECT COUNT(*) FROM `%s` log_visit WHERE referer_type = ? AND $where", + $visitTable + ); + + $acquiredVisits = (int)Db::fetchOne($visitsSql, $visitBind); + + $tables[Metrics::METRIC_AI_ASSISTANTS_UNIQUE_ASSISTANTS] = (int)($row['uniq_bots'] ?? 0); + $tables[Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS] = (int)($row['uniq_pages'] ?? 0); + $tables[Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS] = (int)($row['uniq_downloads'] ?? 0); + $tables[Metrics::METRIC_AI_ASSISTANTS_REQUESTS] = (int)($row['requests'] ?? 0); + $tables[Metrics::METRIC_AI_ASSISTANTS_ACQUIRED_VISITS] = $acquiredVisits; + $tables[Metrics::METRIC_AI_ASSISTANTS_NOT_FOUND_REQUESTS] = (int)($row['not_found_requests'] ?? 0); + $tables[Metrics::METRIC_AI_ASSISTANTS_SERVER_ERROR_REQUESTS] = (int)($row['server_error_requests'] ?? 0); + } +} diff --git a/plugins/BotTracking/Reports/Get.php b/plugins/BotTracking/Reports/Get.php new file mode 100644 index 00000000000..c7aa29aa662 --- /dev/null +++ b/plugins/BotTracking/Reports/Get.php @@ -0,0 +1,92 @@ +categoryId = 'General_AIAssistants'; + $this->subcategoryId = 'BotTracking_AIBotsOverview'; + $this->name = Piwik::translate('BotTracking_ReportTitleBotsOverview'); + $this->documentation = ''; + $this->metrics = Metrics::getReportMetricColumns(); + $this->processedMetrics = [ + new ClickThroughRate(), + ]; + $this->order = 10; + + if (\Piwik\Request::fromRequest()->getStringParameter('period', '') !== 'day') { + $this->metrics = array_filter($this->metrics, function ($metric) { + return !in_array($metric, [Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS]); + }); + } + } + + public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory): void + { + $widgetsList->addWidgetConfig( + $factory->createWidget() + ->setName('BotTracking_ReportTitleBotsOverTime') + ->forceViewDataTable(Evolution::ID) + ->setAction('getEvolutionGraph') + ->setOrder(1) + ); + + $widgetsList->addWidgetConfig( + $factory->createWidget() + ->setName('BotTracking_ReportTitleBotsOverview') + ->forceViewDataTable(Sparklines::ID) + ->setOrder(2) + ); + } + + public function configureView(ViewDataTable $view): void + { + if (!$view->isViewDataTableId(Sparklines::ID)) { + return; + } + + /** @var Sparklines $view */ + $view->config->title = Piwik::translate('BotTracking_ReportTitleBotsOverview'); + $view->config->addTranslations(Metrics::getMetricTranslations()); + $view->config->metrics_documentation = Metrics::getMetricDocumentation(); + + $order = 0; + foreach (Metrics::getSparklineMetricOrder() as $metric) { + if ( + \Piwik\Request::fromRequest()->getStringParameter('period', '') !== 'day' + && in_array($metric, [Metrics::METRIC_AI_ASSISTANTS_UNIQUE_DOCUMENT_URLS, Metrics::METRIC_AI_ASSISTANTS_UNIQUE_PAGE_URLS]) + ) { + continue; + } + $view->config->addSparklineMetric($metric, $order++); + } + + $segment = Request::getRawSegmentFromRequest(); + if (!empty($segment)) { + $view->config->show_footer_message = Piwik::translate('BotTracking_SegmentNotSupported'); + } + } +} diff --git a/plugins/BotTracking/Reports/GetAIAssistantRequests.php b/plugins/BotTracking/Reports/GetAIAssistantRequests.php new file mode 100644 index 00000000000..32ad75dbb02 --- /dev/null +++ b/plugins/BotTracking/Reports/GetAIAssistantRequests.php @@ -0,0 +1,96 @@ +name = Piwik::translate('BotTracking_AIAssistantsReportTitle'); + $this->documentation = Piwik::translate('BotTracking_AIAssistantsReportDocumentation'); + $this->categoryId = 'General_AIAssistants'; + $this->subcategoryId = 'BotTracking_AIBotsOverview'; + $this->dimension = new AIAssistantName(); + $this->metrics = [ + new Requests(), + new PageRequests(), + new DocumentRequests(), + new AcquiredVisits(), + ]; + $this->processedMetrics = []; + $this->order = 30; + $this->defaultSortColumn = Metrics::COLUMN_ACQUIRED_VISITS; + if (\Piwik\Request::fromRequest()->getStringParameter('secondaryDimension', '') === 'documents') { + $this->actionToLoadSubTables = 'getDocumentUrlsForAIAssistant'; + } else { + $this->actionToLoadSubTables = 'getPageUrlsForAIAssistant'; + } + } + + public function configureView(ViewDataTable $view): void + { + parent::configureView($view); + + $view->config->show_table_all_columns = false; + $view->config->show_insights = false; + + // Show segment not supported message when a segment is selected + if (!empty(Request::getRawSegmentFromRequest())) { + $message = '

' . Piwik::translate('BotTracking_SegmentNotSupported') . '

'; + $view->config->show_header_message = $message; + } + + $view->config->setDefaultColumnsToDisplay( + ['label', Metrics::COLUMN_REQUESTS, Metrics::COLUMN_PAGE_REQUESTS, Metrics::COLUMN_DOCUMENT_REQUESTS, Metrics::COLUMN_ACQUIRED_VISITS], + false, + false + ); + + // only show request count for flat table, as subtable doesn't have other metrics + if ((int)$view->requestConfig->getRequestParam('flat') === 1) { + $view->config->setDefaultColumnsToDisplay( + ['label', Metrics::COLUMN_REQUESTS], + false, + false + ); + } + + $secondaryDimensions = [ + 'pages' => Piwik::translate('BotTracking_ColumnPageRequests'), + 'documents' => Piwik::translate('BotTracking_ColumnDocumentRequests'), + ]; + $view->config->setSecondaryDimensions($secondaryDimensions, 'pages'); + } + + /** + * @return void + */ + public function configureWidgets(WidgetsList $widgetsList, ReportWidgetFactory $factory) + { + $widgetsList->addWidgetConfig($factory->createWidget()->setIsWide()); + } +} diff --git a/plugins/BotTracking/Reports/GetDocumentUrlsForAIAssistant.php b/plugins/BotTracking/Reports/GetDocumentUrlsForAIAssistant.php new file mode 100644 index 00000000000..53e7853a0cf --- /dev/null +++ b/plugins/BotTracking/Reports/GetDocumentUrlsForAIAssistant.php @@ -0,0 +1,32 @@ +name = Piwik::translate('BotTracking_AIAssistantsReportTitle'); + $this->categoryId = 'General_AIAssistants'; + $this->metrics = [new Requests()]; + $this->processedMetrics = []; + $this->dimension = new DocumentUrl(); + $this->isSubtableReport = true; + } +} diff --git a/plugins/BotTracking/Reports/GetPageUrlsForAIAssistant.php b/plugins/BotTracking/Reports/GetPageUrlsForAIAssistant.php new file mode 100644 index 00000000000..00a7fa5d7b0 --- /dev/null +++ b/plugins/BotTracking/Reports/GetPageUrlsForAIAssistant.php @@ -0,0 +1,32 @@ +name = Piwik::translate('BotTracking_AIAssistantsReportTitle'); + $this->categoryId = 'General_AIAssistants'; + $this->metrics = [new Requests()]; + $this->processedMetrics = []; + $this->dimension = new PageUrl(); + $this->isSubtableReport = true; + } +} diff --git a/plugins/BotTracking/lang/en.json b/plugins/BotTracking/lang/en.json index 5ab7d81b4bf..c7f0f33a240 100644 --- a/plugins/BotTracking/lang/en.json +++ b/plugins/BotTracking/lang/en.json @@ -1,5 +1,38 @@ { "BotTracking": { - "PluginDescription": "Track and analyze visits from AI bots such as ChatGPT-User and similar. This plugin helps you understand how AI-driven tools interact with your website by logging and reporting bot-triggered requests." + "AIAssistantsReportDocumentation": "Discover which AI assistants are crawling your site and how many hits each one generates. Expand a bot to review the most frequently requested pages or documents.", + "AIAssistantsReportTitle": "AI Assistants - Bots", + "AIAssistantsOverviewHelp1": "The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.", + "AIAssistantsOverviewHelp2": "It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.", + "AIAssistantsOverviewHelp3": "Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.", + "AIBotsOverview": "AI Bots Overview", + "ColumnAIAssistantName": "AI Assistant Name", + "ColumnAcquiredVisits": "Acquired visits", + "ColumnAcquiredVisitsDocumentation": "Visits that started after someone clicked from an AI assistant (Referrers \u2192 AI Assistants).", + "ColumnDocumentRequests": "Document Requests", + "ColumnDocumentRequestsDocumentation": "Total number of bot requests to document URLs recorded during the selected period.", + "ColumnPageRequests": "Page Requests", + "ColumnPageRequestsDocumentation": "Total number of bot requests to page URLs recorded during the selected period.", + "ColumnRequests": "Requests", + "ColumnRequestsDocumentation": "Total number of bot requests recorded during the selected period. Includes both page and document URLs.", + "ColumnUniquePageUrls": "Unique page URLs", + "ColumnUniquePageUrlsDocumentation": "Distinct page URLs requested by AI assistants.", + "ColumnUniqueDocumentUrls": "Unique document URLs", + "ColumnUniqueDocumentUrlsDocumentation": "Distinct download or document URLs requested by AI assistants.", + "ColumnUniqueAiChatbots": "Unique AI chatbots", + "ColumnUniqueAiChatbotsDocumentation": "AI assistant user agents detected during the selected period.", + "ColumnNotFoundRequests": "Not found requests", + "ColumnNotFoundRequestsDocumentation": "Number of bot requests that returned an HTTP 404 or 410 response, including document URLs.", + "ColumnServerErrorRequests": "Server error (5xx) requests", + "ColumnServerErrorRequestsDocumentation": "Number of bot requests that returned an HTTP 5xx response, including document URLs.", + "ColumnClickThroughRate": "Click-through rate", + "ColumnClickThroughRateDocumentation": "Share of AI assistant requests that resulted in acquired visits (acquired visits \u00f7 requests).", + "DocumentUrl": "Document URL", + "PageUrl": "Page URL", + "ReportTitleBotsOverTime": "AI Assistants - Bots Over Time", + "ReportTitleBotsOverview": "AI Assistants - Bots Overview", + "BotsOverTimeReportDocumentation": "This report charts the evolution of AI assistant activity. Click any metric to enlarge its graph and review the underlying trend.", + "PluginDescription": "Track and analyze visits from AI bots such as ChatGPT-User and similar. This plugin helps you understand how AI-driven tools interact with your website by logging and reporting bot-triggered requests.", + "SegmentNotSupported": "Report does not support segmentation. The data displayed is your standard, unsegmented report data." } } diff --git a/plugins/BotTracking/tests/Fixtures/BotTraffic.php b/plugins/BotTracking/tests/Fixtures/BotTraffic.php new file mode 100644 index 00000000000..9fbd922cd6c --- /dev/null +++ b/plugins/BotTracking/tests/Fixtures/BotTraffic.php @@ -0,0 +1,177 @@ +setUpWebsite(); + $this->trackBotRequests(); + $this->trackAcquiredVisits(); + } + + public function tearDown(): void + { + // nothing to clean up + } + + private function setUpWebsite(): void + { + if (!self::siteCreated($this->idSite)) { + self::createWebsite($this->dateTime, 1, 'https://example.com'); + } + } + + private function trackBotRequests(): void + { + $pages = [ + 'https://example.com/article/1/page/1', + 'https://example.com/article/1/page/2', + 'https://example.com/article/2', + 'https://example.com/article/3', + 'https://example.com/article/4/page/1', + 'https://example.com/article/4/page/2', + 'https://example.com/overview', + ]; + + $downloads = [ + 'https://example.com/resources/doc.pdf', + 'https://example.com/resources/guide.pdf', + 'https://example.com/resources/whitepaper.pdf', + 'https://example.com/resources/datasheet.pdf', + 'https://example.com/resources/case-study.pdf', + ]; + + $dailyPlans = [ + 0 => [ + ['ChatGPT-User/1.0', $pages[0], 200, 12005, false], + ['Gemini-Deep-Research/1.0', $pages[1], 200, 29658, false], + ['Perplexity-User/1.0', $downloads[0], 503, 1365955, true], + ['Google-NotebookLM/1.0', $downloads[1], 404, 36522, true], + ['ChatGPT-User/1.0', $pages[0], 200, 12584, false], + ['Gemini-Deep-Research/1.0', $pages[1], 200, 36598, false], + ['Perplexity-User/1.0', $downloads[0], 200, 99562, true], + ['Google-NotebookLM/1.0', $pages[2], 200, 25489, false], + ], + 1 => [ + ['MistralAI-User/2.0', $pages[2], 200, 32485, false], + ['Claude-User/3.0', $downloads[2], 200, 123456, true], + ['ChatGPT-User/1.0', $pages[1], 500, 25896, false], + ['ChatGPT-User/1.0', $downloads[1], 200, 33658, true], + ['Perplexity-User/1.0', $pages[2], 200, 36985, false], + ['Perplexity-User/1.0', $pages[2], 200, 36985, false], + ['MistralAI-User/2.0', $pages[3], 200, 85236, false], + ['Claude-User/3.0', $downloads[3], 200, 12456, true], + ['Claude-User/3.0', $downloads[4], 200, 35562, true], + ], + 2 => [ + ['Perplexity-User/1.0', $downloads[3], 200, 84269, true], + ['Gemini-Deep-Research/1.0', $pages[3], 200, 3265, false], + ['Devin/1.0', $pages[6], 200, 33366, false], + ['ChatGPT-User/1.0', $pages[3], 200, 5454, false], + ['Perplexity-User/1.0', $downloads[2], 200, 69856, true], + ['Gemini-Deep-Research/1.0', $pages[4], 200, 63256, false], + ['Devin/1.0', $pages[6], 200, 25486, false], + ], + 3 => [ + ['MistralAI-User/2.0', $pages[4], 200, 12568, false], + ['Google-NotebookLM/1.0', $downloads[4], 404, 25648, true], + ['ChatGPT-User/1.0', $pages[4], 200, 12548, false], + ['Claude-User/3.0', $pages[5], 503, 36598, false], + ['Perplexity-User/1.0', $downloads[0], 200, 225445, true], + ['MistralAI-User/2.0', $pages[2], 200, 12456, false], + ['Google-NotebookLM/1.0', $downloads[1], 200, 258741, true], + ], + 4 => [ + ['Perplexity-User/1.0', $downloads[1], 200, 36985, true], + ['Gemini-Deep-Research/1.0', $pages[5], 200, 95147, false], + ['ChatGPT-User/1.0', $pages[0], 200, 25412, false], + ['Claude-User/3.0', $pages[3], 200, 36985, false], + ['Perplexity-User/1.0', $downloads[4], 200, 145811, true], + ], + ]; + + foreach ($dailyPlans as $dayOffset => $requests) { + foreach ($requests as $index => $request) { + [$userAgent, $url, $status, $bytes, $isDownload] = $request; + $date = Date::factory($this->dateTime) + ->addDay($dayOffset) + ->addHour(($index + 1) * 2) + ->getDatetime(); + + if ($isDownload) { + $this->logBotDownload($userAgent, $url, $status, $bytes, $date); + } else { + $this->logBot($userAgent, $url, $status, $bytes, $date); + } + } + } + } + + private function trackAcquiredVisits(): void + { + $sources = [ + 'https://chatgpt.com/thread/12345', + 'https://perplexity.ai/share/6789', + 'https://copilot.microsoft.com/answer/abc', + 'https://claude.ai/share/987', + 'https://gemini.google.com/share/notes', + 'https://chat.qwen.ai/share/insight', + 'https://chatgpt.com/thread/8888', + 'https://perplexity.ai/share/2222', + 'https://copilot.microsoft.com/answer/xyz', + 'https://claude.ai/share/1111', + ]; + + foreach ($sources as $index => $referrer) { + $date = Date::factory($this->dateTime) + ->addDay($index % 5) + ->addHour(($index % 4) * 3) + ->getDatetime(); + $tracker = self::getTracker($this->idSite, $date, true); + $tracker->setUrl('https://example.com/article-' . (($index % 4) + 1)); + $tracker->setUrlReferrer($referrer); + self::checkResponse($tracker->doTrackPageView('Article From AI Assistant ' . ($index + 1))); + } + } + + private function logBot(string $userAgent, string $url, int $statusCode, int $bytes, string $dateTime): void + { + $tracker = self::getTracker($this->idSite, $dateTime, true); + $tracker->setUserAgent($userAgent); + $tracker->setUrl($url); + $tracker->setCustomTrackingParameter('recMode', '1'); + $tracker->setCustomTrackingParameter('http_status', (string) $statusCode); + $tracker->setCustomTrackingParameter('bw_bytes', (string) $bytes); + self::checkResponse($tracker->doTrackPageView('')); + } + + private function logBotDownload(string $userAgent, string $url, int $statusCode, int $bytes, string $dateTime): void + { + $tracker = self::getTracker($this->idSite, $dateTime, true); + $tracker->setUserAgent($userAgent); + $tracker->setCustomTrackingParameter('recMode', '1'); + $tracker->setCustomTrackingParameter('http_status', (string) $statusCode); + $tracker->setCustomTrackingParameter('bw_bytes', (string) $bytes); + self::checkResponse($tracker->doTrackAction($url, 'download')); + } +} diff --git a/plugins/BotTracking/tests/Integration/Dao/BotRequestsDaoTest.php b/plugins/BotTracking/tests/Integration/Dao/BotRequestsDaoTest.php index 45377739ba6..e5094121d40 100644 --- a/plugins/BotTracking/tests/Integration/Dao/BotRequestsDaoTest.php +++ b/plugins/BotTracking/tests/Integration/Dao/BotRequestsDaoTest.php @@ -13,6 +13,7 @@ use Piwik\Date; use Piwik\Db; +use Piwik\Plugins\BotTracking\BotDetector; use Piwik\Plugins\BotTracking\Dao\BotRequestsDao; use Piwik\Tests\Framework\Fixture; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -58,7 +59,7 @@ public function testInsertCreatesRecord(): void 'server_time' => '2025-10-28 12:00:00', 'idaction_url' => 123, 'bot_name' => 'ChatGPT-User', - 'bot_type' => 'ai_assistant', + 'bot_type' => BotDetector::BOT_TYPE_AI_ASSISTANT, 'http_status_code' => 200, 'response_size_bytes' => 2048, 'response_time_ms' => 125, @@ -76,7 +77,7 @@ public function testInsertCreatesRecord(): void self::assertNotEmpty($record); self::assertEquals($this->idSite, $record['idsite']); self::assertEquals('ChatGPT-User', $record['bot_name']); - self::assertEquals('ai_assistant', $record['bot_type']); + self::assertEquals(BotDetector::BOT_TYPE_AI_ASSISTANT, $record['bot_type']); self::assertEquals(200, $record['http_status_code']); } @@ -86,7 +87,7 @@ public function testInsertWithNullOptionalFields(): void 'idsite' => $this->idSite, 'server_time' => '2025-10-28 12:00:00', 'bot_name' => 'Claude-User', - 'bot_type' => 'ai_assistant', + 'bot_type' => BotDetector::BOT_TYPE_AI_ASSISTANT, ]; $idRequest = $this->dao->insert($data); @@ -132,7 +133,7 @@ private function insertTestRecord($serverTime, $botName, $idSite = null): int 'idsite' => $idSite, 'server_time' => $serverTime, 'bot_name' => $botName, - 'bot_type' => 'ai_assistant', + 'bot_type' => BotDetector::BOT_TYPE_AI_ASSISTANT, ]; return $this->dao->insert($data); diff --git a/plugins/BotTracking/tests/Integration/TrackerTest.php b/plugins/BotTracking/tests/Integration/TrackerTest.php index a0bd863f332..81fbd68dd03 100644 --- a/plugins/BotTracking/tests/Integration/TrackerTest.php +++ b/plugins/BotTracking/tests/Integration/TrackerTest.php @@ -13,6 +13,7 @@ use Piwik\Common; use Piwik\Db; +use Piwik\Plugins\BotTracking\BotDetector; use Piwik\Plugins\BotTracking\Dao\BotRequestsDao; use Piwik\Tests\Framework\Fixture; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; @@ -66,16 +67,16 @@ public function testAIAssistantIsTrackedCorrectly(string $userAgent, string $exp public function getBotUserAgents(): array { return [ - ['ChatGPT-User/1.0', 'ChatGPT-User', 'ai_assistant'], - ['chatgpt-user/1.0', 'ChatGPT-User', 'ai_assistant'], - ['CHATGPT-USER/1.0', 'ChatGPT-User', 'ai_assistant'], - ['Mozilla/5.0 (compatible; ChatGPT-User/1.0)', 'ChatGPT-User', 'ai_assistant'], - ['Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com)', 'ChatGPT-User', 'ai_assistant'], - ['MistralAI-User/2.0', 'MistralAI-User', 'ai_assistant'], - ['Gemini-Deep-Research/1.0', 'Gemini-Deep-Research', 'ai_assistant'], - ['Claude-User/3.0', 'Claude-User', 'ai_assistant'], - ['Perplexity-User/1.0', 'Perplexity-User', 'ai_assistant'], - ['Devin/1.0', 'Devin', 'ai_assistant'], + ['ChatGPT-User/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['chatgpt-user/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['CHATGPT-USER/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 (compatible; ChatGPT-User/1.0)', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com)', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['MistralAI-User/2.0', 'MistralAI-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Gemini-Deep-Research/1.0', 'Gemini-Deep-Research', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Claude-User/3.0', 'Claude-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Perplexity-User/1.0', 'Perplexity-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Devin/1.0', 'Devin', BotDetector::BOT_TYPE_AI_ASSISTANT], ]; } @@ -94,7 +95,7 @@ public function testActionIdsAreCorrectlyReused(): void self::assertCount(1, $records); self::assertEquals('ChatGPT-User', $records[0]['bot_name']); - self::assertEquals('ai_assistant', $records[0]['bot_type']); + self::assertEquals(BotDetector::BOT_TYPE_AI_ASSISTANT, $records[0]['bot_type']); $tableName = Common::prefixTable('log_visit'); $sql = "SELECT COUNT(*) FROM `{$tableName}` WHERE idsite = ?"; @@ -118,7 +119,7 @@ public function testActionIdsAreCorrectlyReused(): void self::assertCount(2, $records); self::assertEquals('Gemini-Deep-Research', $records[1]['bot_name']); - self::assertEquals('ai_assistant', $records[1]['bot_type']); + self::assertEquals(BotDetector::BOT_TYPE_AI_ASSISTANT, $records[1]['bot_type']); $tableName = Common::prefixTable('log_visit'); $sql = "SELECT COUNT(*) FROM `{$tableName}` WHERE idsite = ?"; @@ -163,7 +164,7 @@ public function testVisitsAndBotsShareActions(): void self::assertCount(1, $records); self::assertEquals('Gemini-Deep-Research', $records[0]['bot_name']); - self::assertEquals('ai_assistant', $records[0]['bot_type']); + self::assertEquals(BotDetector::BOT_TYPE_AI_ASSISTANT, $records[0]['bot_type']); $tableName = Common::prefixTable('log_visit'); $sql = "SELECT COUNT(*) FROM `{$tableName}` WHERE idsite = ?"; diff --git a/plugins/BotTracking/tests/System/ApiTest.php b/plugins/BotTracking/tests/System/ApiTest.php new file mode 100644 index 00000000000..081e154cece --- /dev/null +++ b/plugins/BotTracking/tests/System/ApiTest.php @@ -0,0 +1,104 @@ +runApiTests($api, $params); + } + + public function getApiForTesting() + { + return [ + [ + [ + 'BotTracking.get', + ], [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + ], + ], + [ + [ + 'BotTracking.getAIAssistantRequests', + ], + [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + 'otherRequestParameters' => [ + 'expanded' => 1, + 'secondaryDimension' => 'pages', + ], + 'testSuffix' => '_pages', + ], + ], + [ + [ + 'BotTracking.getAIAssistantRequests', + ], + [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + 'otherRequestParameters' => [ + 'flat' => 1, + ], + 'testSuffix' => '_flat', + ], + ], + [ + [ + 'BotTracking.getAIAssistantRequests', + ], [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + 'otherRequestParameters' => [ + 'expanded' => 1, + 'secondaryDimension' => 'documents', + ], + 'testSuffix' => '_documents', + ], + ], + ]; + } + + public static function getOutputPrefix() + { + return ''; + } + + public static function getPathToTestDirectory() + { + return __DIR__; + } +} + +ApiTest::$fixture = new BotTraffic(); diff --git a/plugins/BotTracking/tests/System/RankingQueryApiTest.php b/plugins/BotTracking/tests/System/RankingQueryApiTest.php new file mode 100644 index 00000000000..990854a3ca3 --- /dev/null +++ b/plugins/BotTracking/tests/System/RankingQueryApiTest.php @@ -0,0 +1,84 @@ +General; + $generalConfig['archiving_ranking_query_row_limit'] = 3; + $generalConfig['datatable_archiving_maximum_rows_bots'] = 0; // no limit here, so we see that the ranking query creates the others row + $generalConfig['datatable_archiving_maximum_rows_subtable_bots'] = 2; + + Cache::flushAll(); + self::deleteArchiveTables(); + + $this->runApiTests(['BotTracking.getAIAssistantRequests'], [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + 'otherRequestParameters' => [ + 'expanded' => 1, + 'secondaryDimension' => 'pages', + ], + 'testSuffix' => 'ranking_limit_pages', + ]); + } + + public function testRankingQueryUsesOthersRowDocuments(): void + { + $generalConfig = &Config::getInstance()->General; + $generalConfig['archiving_ranking_query_row_limit'] = 3; + $generalConfig['datatable_archiving_maximum_rows_bots'] = 4; + $generalConfig['datatable_archiving_maximum_rows_subtable_bots'] = 2; + + Cache::flushAll(); + self::deleteArchiveTables(); + + $this->runApiTests(['BotTracking.getAIAssistantRequests'], [ + 'idSite' => 1, + 'date' => '2025-02-03', + 'periods' => ['day', 'week'], + 'otherRequestParameters' => [ + 'expanded' => 1, + 'secondaryDimension' => 'documents', + ], + 'testSuffix' => 'ranking_limit_documents', + ]); + } + + public static function getOutputPrefix() + { + return ''; + } + + public static function getPathToTestDirectory() + { + return __DIR__; + } +} + +RankingQueryApiTest::$fixture = new BotTraffic(); diff --git a/plugins/BotTracking/tests/System/expected/test___BotTracking.get_day.xml b/plugins/BotTracking/tests/System/expected/test___BotTracking.get_day.xml new file mode 100644 index 00000000000..f38f1f681d3 --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test___BotTracking.get_day.xml @@ -0,0 +1,11 @@ + + + 9 + 2 + 3 + 0 + 4 + 4 + 1 + 0.2222 + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test___BotTracking.get_week.xml b/plugins/BotTracking/tests/System/expected/test___BotTracking.get_week.xml new file mode 100644 index 00000000000..a960214f16f --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test___BotTracking.get_week.xml @@ -0,0 +1,9 @@ + + + 28 + 8 + 1 + 7 + 2 + 0.2857 + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_day.xml b/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_day.xml new file mode 100644 index 00000000000..c7a3e1538ff --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_day.xml @@ -0,0 +1,51 @@ + + + + + 2 + 1 + 1 + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 1 + + + + 3 + 3 + 0 + 0 + + + + 1 + + + + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 0 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_week.xml b/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_week.xml new file mode 100644 index 00000000000..934dc5808bc --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__documents__BotTracking.getAIAssistantRequests_week.xml @@ -0,0 +1,104 @@ + + + + + 5 + 3 + 2 + 2 + + + + 1 + + + + 1 + + + + 1 + + + + + + 7 + 5 + 2 + 2 + + + + 1 + + + + 1 + + + + 1 + + + + 1 + + + + 1 + + + + + + 5 + 1 + 4 + 1 + + + + 1 + + + + + + 3 + 0 + 3 + 1 + + + + 2 + 0 + 2 + 0 + + + + 2 + 2 + 0 + 0 + + + + 1 + + + + 1 + + + + + + 4 + 0 + 4 + 0 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_day.xml b/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_day.xml new file mode 100644 index 00000000000..d8284657b6a --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_day.xml @@ -0,0 +1,27 @@ + + + + + 1 + ChatGPT-User + example.com/article/1/page/2 + + + + 1 + MistralAI-User + example.com/article/2 + + + + 1 + MistralAI-User + example.com/article/3 + + + + 2 + Perplexity-User + example.com/article/2 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_week.xml b/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_week.xml new file mode 100644 index 00000000000..e50cf5d343e --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__flat__BotTracking.getAIAssistantRequests_week.xml @@ -0,0 +1,87 @@ + + + + + 1 + ChatGPT-User + example.com/article/1/page/1 + + + + 1 + ChatGPT-User + example.com/article/1/page/2 + + + + 1 + ChatGPT-User + example.com/article/3 + + + + 1 + ChatGPT-User + example.com/article/4/page/1 + + + + 1 + Claude-User + example.com/article/3 + + + + 1 + Claude-User + example.com/article/4/page/2 + + + + 2 + Devin + example.com/overview + + + + 1 + Gemini-Deep-Research + example.com/article/3 + + + + 1 + Gemini-Deep-Research + example.com/article/4/page/1 + + + + 1 + Gemini-Deep-Research + example.com/article/4/page/2 + + + + 2 + MistralAI-User + example.com/article/2 + + + + 1 + MistralAI-User + example.com/article/3 + + + + 1 + MistralAI-User + example.com/article/4/page/1 + + + + 2 + Perplexity-User + example.com/article/2 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_day.xml b/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_day.xml new file mode 100644 index 00000000000..c5691e39829 --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_day.xml @@ -0,0 +1,53 @@ + + + + + 2 + 1 + 1 + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 1 + + + + 2 + + + + + + 3 + 3 + 0 + 0 + + + + 2 + 0 + 2 + 0 + + + + 1 + + + + 1 + + + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_week.xml b/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_week.xml new file mode 100644 index 00000000000..81b2e6ef81c --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test__pages__BotTracking.getAIAssistantRequests_week.xml @@ -0,0 +1,120 @@ + + + + + 5 + 3 + 2 + 2 + + + + 1 + + + + 1 + + + + + + 7 + 5 + 2 + 2 + + + + 2 + + + + + + 5 + 1 + 4 + 1 + + + + 1 + + + + 1 + + + + 1 + + + + 1 + + + + + + 3 + 0 + 3 + 1 + + + + 1 + + + + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 0 + + + + 2 + + + + + + 2 + 2 + 0 + 0 + + + + 4 + 0 + 4 + 0 + + + + 2 + + + + 1 + + + + 1 + + + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_day.xml b/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_day.xml new file mode 100644 index 00000000000..600a8cdad24 --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_day.xml @@ -0,0 +1,47 @@ + + + + + 2 + 1 + 1 + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 1 + + + + 3 + 3 + 0 + 0 + + + + 1 + + + + 2 + + + + + + 2 + 0 + 2 + 0 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_week.xml b/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_week.xml new file mode 100644 index 00000000000..c2ad1e8577a --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test_ranking_limit_documents__BotTracking.getAIAssistantRequests_week.xml @@ -0,0 +1,57 @@ + + + + + 6 + 4 + 2 + 2 + + + + 1 + + + + 3 + + + + + + 5 + 1 + 4 + 1 + + + + 1 + + + + + + 4 + 3 + 1 + 1 + + + + 1 + + + + 2 + + + + + + 13 + 3 + 10 + 2 + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_day.xml b/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_day.xml new file mode 100644 index 00000000000..950605adb52 --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_day.xml @@ -0,0 +1,53 @@ + + + + + 2 + 1 + 1 + 1 + + + + 1 + + + + + + 2 + 0 + 2 + 1 + + + + 2 + + + + + + 3 + 3 + 0 + 0 + + + + 2 + 0 + 2 + 0 + + + + 1 + + + + 1 + + + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_week.xml b/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_week.xml new file mode 100644 index 00000000000..be3f411ed71 --- /dev/null +++ b/plugins/BotTracking/tests/System/expected/test_ranking_limit_pages__BotTracking.getAIAssistantRequests_week.xml @@ -0,0 +1,104 @@ + + + + + 5 + 3 + 2 + 2 + + + + 1 + + + + 1 + + + + + + 7 + 5 + 2 + 2 + + + + 2 + + + + + + 5 + 1 + 4 + 1 + + + + 1 + + + + 3 + + + + + + 3 + 0 + 3 + 1 + + + + 1 + + + + 2 + + + + + + 2 + 0 + 2 + 0 + + + + 2 + + + + + + 2 + 2 + 0 + 0 + + + + 4 + 0 + 4 + 0 + + + + 2 + + + + 2 + + + + \ No newline at end of file diff --git a/plugins/BotTracking/tests/UI/BotTracking_spec.js b/plugins/BotTracking/tests/UI/BotTracking_spec.js new file mode 100644 index 00000000000..e8d2f4dd6b0 --- /dev/null +++ b/plugins/BotTracking/tests/UI/BotTracking_spec.js @@ -0,0 +1,78 @@ +/*! + * Matomo - free/libre analytics platform + * + * Screenshot integration tests. + * + * @link https://matomo.org + * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later + */ + +describe("BotTracking", function () { + this.timeout(0); + + this.fixture = "Piwik\\Plugins\\BotTracking\\tests\\Fixtures\\BotTraffic"; + + var generalParams = 'idSite=1&period=day&date=2025-02-02', + urlBase = 'module=CoreHome&action=index&' + generalParams; + + it('should render AI Assistants > AI Bots Overview page with evolution and sparkline', async function () { + await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIBotsOverview"); + await page.waitForNetworkIdle(); + + await page.hover('.jqplot-seriespicker'); + + const availableMetrics = await page.$$('.jqplot-seriespicker input.select'); + expect(availableMetrics.length).to.equal(8); + + await page.mouse.move(0, 0); + + const sparklines = await page.$$('.sparkline-metrics'); + expect(sparklines.length).to.equal(8); + + var elem = await page.$('.pageWrap'); + expect(await elem.screenshot()).to.matchImage('bot_overview'); + }); + + it('should not show unique pages and documents metric for higher periods', async function () { + await page.goto("?" + urlBase + "#?idSite=1&period=week&date=2025-02-02&category=General_AIAssistants&subcategory=BotTracking_AIBotsOverview"); + await page.waitForNetworkIdle(); + + await page.hover('.jqplot-seriespicker'); + + const availableMetrics = await page.$$('.jqplot-seriespicker input.select'); + expect(availableMetrics.length).to.equal(6); + + const sparklines = await page.$$('.sparkline-metrics'); + expect(sparklines.length).to.equal(6); + }); + + it('should render AI Assistants > AI Bots Overview bot detail report', async function () { + await page.goto("?" + urlBase + "#?" + generalParams + "&category=General_AIAssistants&subcategory=BotTracking_AIBotsOverview"); + await page.waitForNetworkIdle(); + + const row = await page.jQuery('tr.subDataTable:first'); + await row.click(); + await page.mouse.move(-10, -10); + + await page.waitForNetworkIdle(); + await page.waitForTimeout(250); // rendering + + var elem = await page.$('#widgetBotTrackinggetAIAssistantRequests'); + expect(await elem.screenshot()).to.matchImage('bot_requests'); + }); + + it('should switch to secondary dimension when clicked', async function () { + await page.evaluate(() => $('.datatableRelatedReports li span:contains("Document Requests")').click()); + await page.waitForNetworkIdle(); + + const row = await page.jQuery('tr.subDataTable:first'); + await row.click(); + await page.mouse.move(-10, -10); + + await page.waitForNetworkIdle(); + await page.waitForTimeout(250); // rendering + + var elem = await page.$('#widgetBotTrackinggetAIAssistantRequests'); + expect(await elem.screenshot()).to.matchImage('bot_requests_documents'); + }); +}); diff --git a/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_overview.png b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_overview.png new file mode 100644 index 00000000000..7486846593a --- /dev/null +++ b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_overview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b47250814dcfe7b5af27aaa5a6810cb8c7e90776b19b8150d860fbc86962dfe2 +size 85768 diff --git a/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests.png b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests.png new file mode 100644 index 00000000000..b521b5b7f07 --- /dev/null +++ b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88f59825caccd2e41522a9ec4f7dc1ac1672ee39d10dbdc970fff920ce555174 +size 44400 diff --git a/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests_documents.png b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests_documents.png new file mode 100644 index 00000000000..b5e875a6b23 --- /dev/null +++ b/plugins/BotTracking/tests/UI/expected-screenshots/BotTracking_bot_requests_documents.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d99c95f3126c5b9da5da674d2c9346e5dcc8e3ae50c057777a71a472c99eb57f +size 44622 diff --git a/plugins/BotTracking/tests/Unit/BotDetectorTest.php b/plugins/BotTracking/tests/Unit/BotDetectorTest.php index 68278d1c8f5..2b2fbe41f10 100644 --- a/plugins/BotTracking/tests/Unit/BotDetectorTest.php +++ b/plugins/BotTracking/tests/Unit/BotDetectorTest.php @@ -57,16 +57,16 @@ public function testDetectReturnsNullForNonBots(string $userAgent): void public function getBotUserAgents(): array { return [ - ['ChatGPT-User/1.0', 'ChatGPT-User', 'ai_assistant'], - ['chatgpt-user/1.0', 'ChatGPT-User', 'ai_assistant'], - ['CHATGPT-USER/1.0', 'ChatGPT-User', 'ai_assistant'], - ['Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com)', 'ChatGPT-User', 'ai_assistant'], - ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; MistralAI-User/1.0; +https://docs.mistral.ai/robots)', 'MistralAI-User', 'ai_assistant'], - ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Gemini-Deep-Research; +https://gemini.google/overview/deep-research/) Chrome/135.0.0.0 Safari/537.36', 'Gemini-Deep-Research', 'ai_assistant'], - ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)', 'Claude-User', 'ai_assistant'], - ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)', 'Perplexity-User', 'ai_assistant'], - ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36; Devin/1.0; +devin.ai', 'Devin', 'ai_assistant'], - ['Google-NotebookLM', 'Google-NotebookLM', 'ai_assistant'], + ['ChatGPT-User/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['chatgpt-user/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['CHATGPT-USER/1.0', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com)', 'ChatGPT-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; MistralAI-User/1.0; +https://docs.mistral.ai/robots)', 'MistralAI-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Gemini-Deep-Research; +https://gemini.google/overview/deep-research/) Chrome/135.0.0.0 Safari/537.36', 'Gemini-Deep-Research', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-User/1.0; +Claude-User@anthropic.com)', 'Claude-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)', 'Perplexity-User', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36; Devin/1.0; +devin.ai', 'Devin', BotDetector::BOT_TYPE_AI_ASSISTANT], + ['Google-NotebookLM', 'Google-NotebookLM', BotDetector::BOT_TYPE_AI_ASSISTANT], ]; } diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_expanded.png b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_expanded.png index 26c842f91f4..84c089e8eb0 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_expanded.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_expanded.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b38c50c17c87fe04f757f487ecc7db313d4ea3c730a88118971802d1b4038341 -size 49988 +oid sha256:82cdb653d3c13784a393ac829c60c40829e7e744f956beffacf7c10acc16f511 +size 52164 diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_list_shown.png b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_list_shown.png index 6fa9783af72..921dcb64f66 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_list_shown.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_list_shown.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f73785263642d9519a7a723597a8aca3f17dc3e9a1847607a597ffb14ee94bcd -size 64019 +oid sha256:d0613b5394c3156fc34831439415f37f897700a59fa9b7cf741df0b96552a71a +size 66203 diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_preview.png b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_preview.png index 4437a925316..160367a4af5 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_preview.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/DashboardManager_widget_preview.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cda2b59ed6b52fcd0da287c4f98a79b1fd937f5bbb3eac005a8e0e04d62aebfa -size 77931 +oid sha256:938f0391c1888a068abbcb3fe6686c53e8405b62a0d068386a7232e2f3bf5734 +size 79899 diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard2.png b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard2.png index 0486f01a0b1..b7967a17bdd 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard2.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d48bc98b7adede3e240341d7516953c96f55bcdbee04f23c54616811366a1fdf -size 739020 +oid sha256:b3cc1eac7805ff1d91409f8c2a83aa9e533a5b2954c20a0f4dd1888de848ef75 +size 734678 diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard3.png b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard3.png index 1794368711f..8897e3ccf7e 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard3.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard3.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:09877a69d9b3d471bd42a8a2be43a95fe7b1fb92d2d8f51c96bab7c49c7c0f3b -size 1624733 +oid sha256:4d2b08e12cc4dd7e0fa82a0e0c3dba1c6ba3f45f5b3bfbdbbb518f15a3e8417f +size 1653023 diff --git a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard4.png b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard4.png index a76323f07f9..1457a48707c 100644 --- a/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard4.png +++ b/plugins/Dashboard/tests/UI/expected-screenshots/Dashboard_dashboard4.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d41e8dc81549362f345bde2be1c9352459b8b2bf77292806ba8727830860de1 -size 748178 +oid sha256:6b59d0565bcd429cc4d4d7f24362879abd9b340e7ff9000dbca7e5775b5b4f28 +size 754556 diff --git a/plugins/Diagnostics/tests/Integration/Commands/AnalyzeArchiveTableTest.php b/plugins/Diagnostics/tests/Integration/Commands/AnalyzeArchiveTableTest.php index fa3813ba92f..f1bc6aab7a8 100644 --- a/plugins/Diagnostics/tests/Integration/Commands/AnalyzeArchiveTableTest.php +++ b/plugins/Diagnostics/tests/Integration/Commands/AnalyzeArchiveTableTest.php @@ -42,9 +42,9 @@ public function testCommandOutputIsAsExpected() +-------------------------------------------+------------+---------------+-------------+---------+-----------+----------------+-------------+-------------+ | Group | # Archives | # Invalidated | # Temporary | # Error | # Segment | # Numeric Rows | # Blob Rows | # Blob Data | +-------------------------------------------+------------+---------------+-------------+---------+-----------+----------------+-------------+-------------+ -| day[2010-03-06 - 2010-03-06] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 115 | 77 | %d | -| week[2010-03-01 - 2010-03-07] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 157 | 99 | %d | -| month[2010-03-01 - 2010-03-31] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 157 | 99 | %d | +| day[2010-03-06 - 2010-03-06] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 122 | 81 | %d | +| week[2010-03-01 - 2010-03-07] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 164 | 103 | %d | +| month[2010-03-01 - 2010-03-31] idSite = 1 | 9 | 0 | 0 | 0 | 8 | 164 | 103 | %d | +-------------------------------------------+------------+---------------+-------------+---------+-----------+----------------+-------------+-------------+ Total # Archives: 27 diff --git a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_and_graph__ScheduledReports.generateReport_week.original.html b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_and_graph__ScheduledReports.generateReport_week.original.html index 7105c448dde..360a789fae1 100644 --- a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_and_graph__ScheduledReports.generateReport_week.original.html +++ b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_and_graph__ScheduledReports.generateReport_week.original.html @@ -432,6 +432,16 @@

AI Agent Visits + +
  • + + AI Assistants - Bots Overview + +
  • +
  • + + AI Assistants - Bots +
  • @@ -7964,6 +7974,106 @@

    + AI Assistants - Bots Overview +

    + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +  Name   + +  Value   +
    + Requests + 0 +
    + Acquired visits + 0 +
    + Not found requests + 0 +
    + Unique AI chatbots + 0 +
    + Server error (5xx) requests + 0 +
    + Click-through rate + 0 +
    + Unique page URLs + 0 +
    + Unique document URLs + 0 +
    +

    + + Back to top ↑ +

    + +

    + AI Assistants - Bots +

    + + There is no data for this report. +

    Data tables

    diff --git a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_only__ScheduledReports.generateReport_week.original.html b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_only__ScheduledReports.generateReport_week.original.html index 14a6b4cfdf9..15527677c50 100644 --- a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_only__ScheduledReports.generateReport_week.original.html +++ b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_html_tables_only__ScheduledReports.generateReport_week.original.html @@ -432,6 +432,16 @@

    AI Agent Visits +

  • +
  • + + AI Assistants - Bots Overview + +
  • +
  • + + AI Assistants - Bots +
  • @@ -7558,6 +7568,99 @@

    + AI Assistants - Bots Overview +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +  Name   + +  Value   +
    + Requests + 0 +
    + Acquired visits + 0 +
    + Not found requests + 0 +
    + Unique AI chatbots + 0 +
    + Server error (5xx) requests + 0 +
    + Click-through rate + 0 +
    + Unique page URLs + 0 +
    + Unique document URLs + 0 +
    +

    + + Back to top ↑ +

    + +

    + AI Assistants - Bots +

    + + There is no data for this report. +

    Data tables

    diff --git a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_csv__ScheduledReports.generateReport_week.original.csv b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_csv__ScheduledReports.generateReport_week.original.csv index f1ad995b4e9..41cd5d7df5e 100644 --- a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_csv__ScheduledReports.generateReport_week.original.csv +++ b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_csv__ScheduledReports.generateReport_week.original.csv @@ -552,6 +552,13 @@ AI Agent Visits nb_uniq_visitors_ai_agent,nb_users_ai_agent,nb_visits_ai_agent,nb_actions_ai_agent,max_actions_ai_agent,bounce_rate_ai_agent,nb_actions_per_visit_ai_agent,avg_time_on_site_ai_agent,nb_uniq_visitors_human,nb_users_human,nb_visits_human,nb_actions_human,max_actions_human,bounce_rate_human,nb_actions_per_visit_human,avg_time_on_site_human 0,0,0,0,0,0%,0,00:00:00,1,0,5,16,6,20%,3.2,00:22:50 +AI Assistants - Bots Overview +BotTracking_AIAssistantsRequests,BotTracking_AIAssistantsAcquiredVisits,BotTracking_AIAssistantsNotFoundRequests,BotTracking_AIAssistantsUniqueAssistants,BotTracking_AIAssistantsServerErrorRequests,BotTracking_AIAssistantsClickThroughRate,BotTracking_AIAssistantsUniquePageUrls,BotTracking_AIAssistantsUniqueDocumentUrls +0,0,0,0,0,0,0,0 + +AI Assistants - Bots +No data available + Data tables No data available diff --git a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_week.original.pdf b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_week.original.pdf index be81bb22ba7..8d16ba30451 100644 Binary files a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_week.original.pdf and b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_week.original.pdf differ diff --git a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_tsv__ScheduledReports.generateReport_week.original.tsv b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_tsv__ScheduledReports.generateReport_week.original.tsv index a2fc5d72326..9078871fe08 100644 --- a/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_tsv__ScheduledReports.generateReport_week.original.tsv +++ b/plugins/Ecommerce/tests/System/expected/test_ecommerceOrderWithItems_schedrep_in_tsv__ScheduledReports.generateReport_week.original.tsv @@ -552,6 +552,13 @@ AI Agent Visits nb_uniq_visitors_ai_agent nb_users_ai_agent nb_visits_ai_agent nb_actions_ai_agent max_actions_ai_agent bounce_rate_ai_agent nb_actions_per_visit_ai_agent avg_time_on_site_ai_agent nb_uniq_visitors_human nb_users_human nb_visits_human nb_actions_human max_actions_human bounce_rate_human nb_actions_per_visit_human avg_time_on_site_human 0 0 0 0 0 0% 0 00:00:00 1 0 5 16 6 20% 3.2 00:22:50 +AI Assistants - Bots Overview +BotTracking_AIAssistantsRequests BotTracking_AIAssistantsAcquiredVisits BotTracking_AIAssistantsNotFoundRequests BotTracking_AIAssistantsUniqueAssistants BotTracking_AIAssistantsServerErrorRequests BotTracking_AIAssistantsClickThroughRate BotTracking_AIAssistantsUniquePageUrls BotTracking_AIAssistantsUniqueDocumentUrls +0 0 0 0 0 0 0 0 + +AI Assistants - Bots +No data available + Data tables No data available diff --git a/plugins/ExamplePlugin/tests/System/expected/test___API.get_day.xml b/plugins/ExamplePlugin/tests/System/expected/test___API.get_day.xml index b8308b4e945..868cb850da1 100644 --- a/plugins/ExamplePlugin/tests/System/expected/test___API.get_day.xml +++ b/plugins/ExamplePlugin/tests/System/expected/test___API.get_day.xml @@ -69,6 +69,13 @@ 0 0 0% + 0 + 0 + 0 + 0 + 0 + 0 + 0 0 0 0 @@ -106,4 +113,5 @@ 0% 2 632 + 0 \ No newline at end of file diff --git a/plugins/IntranetMeasurable/tests/System/expected/test__intranet__API.get_day.xml b/plugins/IntranetMeasurable/tests/System/expected/test__intranet__API.get_day.xml index 348b875927c..1356290c448 100644 --- a/plugins/IntranetMeasurable/tests/System/expected/test__intranet__API.get_day.xml +++ b/plugins/IntranetMeasurable/tests/System/expected/test__intranet__API.get_day.xml @@ -69,6 +69,13 @@ 0 0 0% + 0 + 0 + 0 + 0 + 0 + 0 + 0 0 0 0 @@ -106,4 +113,5 @@ 0% 2 361 + 0 \ No newline at end of file diff --git a/plugins/IntranetMeasurable/tests/System/expected/test__notIntranet__API.get_day.xml b/plugins/IntranetMeasurable/tests/System/expected/test__notIntranet__API.get_day.xml index d9f70926c55..7ffa9caa583 100644 --- a/plugins/IntranetMeasurable/tests/System/expected/test__notIntranet__API.get_day.xml +++ b/plugins/IntranetMeasurable/tests/System/expected/test__notIntranet__API.get_day.xml @@ -69,6 +69,13 @@ 0 0 0% + 0 + 0 + 0 + 0 + 0 + 0 + 0 0 0 0 @@ -106,4 +113,5 @@ 0% 4 361 + 0 \ No newline at end of file diff --git a/plugins/PrivacyManager/tests/System/expected/test___PrivacyManager.getComplianceStatus.xml b/plugins/PrivacyManager/tests/System/expected/test___PrivacyManager.getComplianceStatus.xml index 5901f3f4ee0..b5cdcaebc60 100644 --- a/plugins/PrivacyManager/tests/System/expected/test___PrivacyManager.getComplianceStatus.xml +++ b/plugins/PrivacyManager/tests/System/expected/test___PrivacyManager.getComplianceStatus.xml @@ -17,8 +17,8 @@ Ecommerce Restricted non_compliant Non compliant because ecommerce analytics is enabled unrestricted. - - + + PII data filtered non_compliant PII data must be filtered by the Matomo-recommended PII exclusion list. diff --git a/tests/PHPUnit/Framework/Fixture.php b/tests/PHPUnit/Framework/Fixture.php index e11e0db615e..f2bddf6c4da 100644 --- a/tests/PHPUnit/Framework/Fixture.php +++ b/tests/PHPUnit/Framework/Fixture.php @@ -529,7 +529,7 @@ public static function unloadAllPlugins() * * @param string $dateTime eg '2010-01-01 12:34:56' * @param int $ecommerce - * @param bool $siteName + * @param bool|string $siteName * @param bool|string $siteUrl * @param int $siteSearch * @param null|string $searchKeywordParameters diff --git a/tests/PHPUnit/Framework/TestRequest/Collection.php b/tests/PHPUnit/Framework/TestRequest/Collection.php index c7a5c44816d..aa6a8a55bae 100644 --- a/tests/PHPUnit/Framework/TestRequest/Collection.php +++ b/tests/PHPUnit/Framework/TestRequest/Collection.php @@ -53,6 +53,7 @@ class Collection 'Referrers.getKeywordNotDefinedString', 'CorePluginsAdmin.getSystemSettings', 'API.getPagesComparisonsDisabledFor', + 'BotTracking', ); /** diff --git a/tests/PHPUnit/Integration/Archive/PartialArchiveTest.php b/tests/PHPUnit/Integration/Archive/PartialArchiveTest.php index 4b6a9a01779..f74d8f6e1b6 100644 --- a/tests/PHPUnit/Integration/Archive/PartialArchiveTest.php +++ b/tests/PHPUnit/Integration/Archive/PartialArchiveTest.php @@ -63,7 +63,7 @@ public function testRangeArchivingOnlyArchivesSingleRecordWhenQueryingNumerics() // check archive is all plugins archive as expected [$idArchives, $archiveInfo] = $this->getArchiveInfo('2020_04', Range::PERIOD_ID, false); $this->assertEquals([ - ['idsite' => 1, 'date1' => '2020-04-06', 'date2' => '2020-04-09', 'period' => Range::PERIOD_ID, 'name' => 'done', 'value' => ArchiveWriter::DONE_OK, 'blob_count' => 60], + ['idsite' => 1, 'date1' => '2020-04-06', 'date2' => '2020-04-09', 'period' => Range::PERIOD_ID, 'name' => 'done', 'value' => ArchiveWriter::DONE_OK, 'blob_count' => 64], ], $archiveInfo); $maxIdArchive = $this->getMaxIdArchive('2020_04'); @@ -124,7 +124,7 @@ public function testRangeArchivingOnlyArchivesSingleRecordWhenQueryingBlobs() // check archive is all plugins archive as expected [$idArchives, $archiveInfo] = $this->getArchiveInfo('2020_04', Range::PERIOD_ID, false); $this->assertEquals([ - ['idsite' => 1, 'date1' => '2020-04-06', 'date2' => '2020-04-09', 'period' => Range::PERIOD_ID, 'name' => 'done', 'value' => ArchiveWriter::DONE_OK, 'blob_count' => 60], + ['idsite' => 1, 'date1' => '2020-04-06', 'date2' => '2020-04-09', 'period' => Range::PERIOD_ID, 'name' => 'done', 'value' => ArchiveWriter::DONE_OK, 'blob_count' => 64], ], $archiveInfo); $maxIdArchive = $this->getMaxIdArchive('2020_04'); diff --git a/tests/PHPUnit/Integration/WidgetsListTest.php b/tests/PHPUnit/Integration/WidgetsListTest.php index f937643f24d..15a5fc79235 100644 --- a/tests/PHPUnit/Integration/WidgetsListTest.php +++ b/tests/PHPUnit/Integration/WidgetsListTest.php @@ -52,7 +52,7 @@ public function testGet() 'Referrers_Referrers' => 11, 'About Matomo' => 11, 'Marketplace_Marketplace' => 3, - 'General_AIAssistants' => 2, + 'General_AIAssistants' => 5, // widgets provided by Professional Services plugin for plugin promos 'ProfessionalServices_PromoAbTesting' => 1, diff --git a/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits_csv__API.get_month.csv b/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits_csv__API.get_month.csv index ade12583fc0..824fc8cfc7e 100644 Binary files a/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits_csv__API.get_month.csv and b/tests/PHPUnit/System/expected/test_OneVisitorTwoVisits_csv__API.get_month.csv differ diff --git a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_and_graph__ScheduledReports.generateReport_month.original.html b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_and_graph__ScheduledReports.generateReport_month.original.html index ab572f8cfcf..20cd3f3911d 100644 --- a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_and_graph__ScheduledReports.generateReport_month.original.html +++ b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_and_graph__ScheduledReports.generateReport_month.original.html @@ -372,6 +372,16 @@

    AI Agent Visits +

  • +
  • + + AI Assistants - Bots Overview + +
  • +
  • + + AI Assistants - Bots +
  • @@ -6105,6 +6115,106 @@

    + AI Assistants - Bots Overview +

    + + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +  Name   + +  Value   +
    + Requests + 0 +
    + Acquired visits + 0 +
    + Not found requests + 0 +
    + Unique AI chatbots + 0 +
    + Server error (5xx) requests + 0 +
    + Click-through rate + 0 +
    + Unique page URLs + 0 +
    + Unique document URLs + 0 +
    +

    + + Back to top ↑ +

    + +

    + AI Assistants - Bots +

    + + There is no data for this report. +

    Data tables

    diff --git a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_only__ScheduledReports.generateReport_month.original.html b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_only__ScheduledReports.generateReport_month.original.html index cb9f4312d76..dc596a4768e 100644 --- a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_only__ScheduledReports.generateReport_month.original.html +++ b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_html_tables_only__ScheduledReports.generateReport_month.original.html @@ -372,6 +372,16 @@

    AI Agent Visits +

  • +
  • + + AI Assistants - Bots Overview + +
  • +
  • + + AI Assistants - Bots +
  • @@ -5797,6 +5807,99 @@

    + AI Assistants - Bots Overview +

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +  Name   + +  Value   +
    + Requests + 0 +
    + Acquired visits + 0 +
    + Not found requests + 0 +
    + Unique AI chatbots + 0 +
    + Server error (5xx) requests + 0 +
    + Click-through rate + 0 +
    + Unique page URLs + 0 +
    + Unique document URLs + 0 +
    +

    + + Back to top ↑ +

    + +

    + AI Assistants - Bots +

    + + There is no data for this report. +

    Data tables

    diff --git a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_csv__ScheduledReports.generateReport_month.original.csv b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_csv__ScheduledReports.generateReport_month.original.csv index d41700aa45d..b8235d5aa21 100644 --- a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_csv__ScheduledReports.generateReport_month.original.csv +++ b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_csv__ScheduledReports.generateReport_month.original.csv @@ -378,6 +378,13 @@ AI Agent Visits nb_uniq_visitors_ai_agent,nb_users_ai_agent,nb_visits_ai_agent,nb_actions_ai_agent,max_actions_ai_agent,bounce_rate_ai_agent,nb_actions_per_visit_ai_agent,avg_time_on_site_ai_agent,nb_uniq_visitors_human,nb_users_human,nb_visits_human,nb_actions_human,max_actions_human,bounce_rate_human,nb_actions_per_visit_human,avg_time_on_site_human 0,0,0,0,0,0%,0,00:00:00,2,0,11,43,5,27%,3.9,00:10:55 +AI Assistants - Bots Overview +BotTracking_AIAssistantsRequests,BotTracking_AIAssistantsAcquiredVisits,BotTracking_AIAssistantsNotFoundRequests,BotTracking_AIAssistantsUniqueAssistants,BotTracking_AIAssistantsServerErrorRequests,BotTracking_AIAssistantsClickThroughRate,BotTracking_AIAssistantsUniquePageUrls,BotTracking_AIAssistantsUniqueDocumentUrls +0,0,0,0,0,0,0,0 + +AI Assistants - Bots +No data available + Data tables No data available diff --git a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_month.original.pdf b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_month.original.pdf index 31e6fdf5412..8adcce11f11 100644 Binary files a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_month.original.pdf and b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_pdf_tables_only__ScheduledReports.generateReport_month.original.pdf differ diff --git a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_tsv__ScheduledReports.generateReport_month.original.tsv b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_tsv__ScheduledReports.generateReport_month.original.tsv index be63321aed7..aef2b3643dd 100644 --- a/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_tsv__ScheduledReports.generateReport_month.original.tsv +++ b/tests/PHPUnit/System/expected/test_TwoVisitors_twoWebsites_differentDays_schedrep_in_tsv__ScheduledReports.generateReport_month.original.tsv @@ -378,6 +378,13 @@ AI Agent Visits nb_uniq_visitors_ai_agent nb_users_ai_agent nb_visits_ai_agent nb_actions_ai_agent max_actions_ai_agent bounce_rate_ai_agent nb_actions_per_visit_ai_agent avg_time_on_site_ai_agent nb_uniq_visitors_human nb_users_human nb_visits_human nb_actions_human max_actions_human bounce_rate_human nb_actions_per_visit_human avg_time_on_site_human 0 0 0 0 0 0% 0 00:00:00 2 0 11 43 5 27% 3.9 00:10:55 +AI Assistants - Bots Overview +BotTracking_AIAssistantsRequests BotTracking_AIAssistantsAcquiredVisits BotTracking_AIAssistantsNotFoundRequests BotTracking_AIAssistantsUniqueAssistants BotTracking_AIAssistantsServerErrorRequests BotTracking_AIAssistantsClickThroughRate BotTracking_AIAssistantsUniquePageUrls BotTracking_AIAssistantsUniqueDocumentUrls +0 0 0 0 0 0 0 0 + +AI Assistants - Bots +No data available + Data tables No data available diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryMetrics.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryMetrics.xml index fcc1fee9470..e01da845e90 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryMetrics.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryMetrics.xml @@ -1,5 +1,15 @@ + + Acquired visits + BotTracking_AIAssistantsAcquiredVisits + Visits that started after someone clicked from an AI assistant (Referrers → AI Assistants). + + + Acquired visits + visits_acquired + Visits that started after someone clicked from an AI assistant (Referrers → AI Assistants). + Actions nb_actions @@ -75,6 +85,11 @@ entry_bounce_count Number of visits that started and ended on this page. This means that the visitor left the website after viewing only this page. + + Click-through rate + BotTracking_AIAssistantsClickThroughRate + Share of AI assistant requests that resulted in acquired visits (acquired visits ÷ requests). + Clicked in search results nb_hits_following_search @@ -90,6 +105,11 @@ conversion_rate The percentage of visits that triggered a conversion. The conversion rate is calculated using the number of visits that converted at least one goal. Visits converting multiple goals are only counted once in the conversion rate. + + Document Requests + document_requests + Total number of bot requests to document URLs recorded during the selected period. + Downloads nb_downloads @@ -150,11 +170,21 @@ min_event_value The minimum value for this event + + Not found requests + BotTracking_AIAssistantsNotFoundRequests + Number of bot requests that returned an HTTP 404 or 410 response, including document URLs. + Outlinks nb_outlinks The number of times this link was clicked. + + Page Requests + page_requests + Total number of bot requests to page URLs recorded during the selected period. + Pageviews nb_hits @@ -170,6 +200,16 @@ revenue The total revenue generated by Product sales. Excludes tax, shipping and discount. + + Requests + BotTracking_AIAssistantsRequests + Total number of bot requests recorded during the selected period. Includes both page and document URLs. + + + Requests + requests + Total number of bot requests recorded during the selected period. Includes both page and document URLs. + Search Results pages nb_pages_per_search @@ -180,6 +220,16 @@ nb_searches The number of visits that searched for this keyword on your website's search engine. + + Server error (5xx) requests + BotTracking_AIAssistantsServerErrorRequests + Number of bot requests that returned an HTTP 5xx response, including document URLs. + + + Unique AI chatbots + BotTracking_AIAssistantsUniqueAssistants + AI assistant user agents detected during the selected period. + Unique Downloads nb_uniq_downloads @@ -195,6 +245,16 @@ nb_uniq_pageviews The number of visits that included this page. If a page was viewed multiple times during one visit, it is only counted once. + + Unique document URLs + BotTracking_AIAssistantsUniqueDocumentUrls + Distinct download or document URLs requested by AI assistants. + + + Unique page URLs + BotTracking_AIAssistantsUniquePageUrls + Distinct page URLs requested by AI assistants. + Unique visitors nb_uniq_visitors diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryReports.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryReports.xml index 1bda0adfddd..a4db69521ee 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryReports.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getGlossaryReports.xml @@ -4,6 +4,10 @@ AI Assistants (Referrers) This report shows which AI assistants led visitors to your website.<br />By clicking on a row in the table, you can see from which AI assistant pages visitors came to your website. + + AI Assistants - Bots (AI Assistants) + Discover which AI assistants are crawling your site and how many hits each one generates. Expand a bot to review the most frequently requested pages or documents. + Actions - Main metrics (Actions) This report provides a very basic overview of what actions your visitors take on your website. diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportMetadata_day.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportMetadata_day.xml index e9a271b382b..1fdcc750c55 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportMetadata_day.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportMetadata_day.xml @@ -3592,6 +3592,83 @@ index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=AIAgents&apiAction=get&period=day&date=2008-12-06,2009-01-04 AIAgents_get + + AI Assistants + AI Bots Overview + AI Assistants - Bots Overview + BotTracking + get + + Requests + Acquired visits + Unique page URLs + Not found requests + Unique AI chatbots + Unique document URLs + Server error (5xx) requests + + + Total number of bot requests recorded during the selected period. Includes both page and document URLs. + Visits that started after someone clicked from an AI assistant (Referrers → AI Assistants). + Distinct page URLs requested by AI assistants. + Number of bot requests that returned an HTTP 404 or 410 response, including document URLs. + AI assistant user agents detected during the selected period. + Distinct download or document URLs requested by AI assistants. + Number of bot requests that returned an HTTP 5xx response, including document URLs. + Share of AI assistant requests that resulted in acquired visits (acquired visits ÷ requests). + + + Click-through rate + + + number + number + number + number + number + number + number + percent + + index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=BotTracking&apiAction=get&period=day&date=2008-12-06,2009-01-04 + index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=BotTracking&apiAction=get&period=day&date=2008-12-06,2009-01-04 + BotTracking_get + + + AI Assistants + AI Bots Overview + AI Assistants - Bots + BotTracking + getAIAssistantRequests + AI Assistant Name + Discover which AI assistants are crawling your site and how many hits each one generates. Expand a bot to review the most frequently requested pages or documents. + + AI Assistant Name + Page URL + + + Requests + Page Requests + Document Requests + Acquired visits + + + Total number of bot requests recorded during the selected period. Includes both page and document URLs. + Total number of bot requests to page URLs recorded during the selected period. + Total number of bot requests to document URLs recorded during the selected period. + Visits that started after someone clicked from an AI assistant (Referrers → AI Assistants). + + + number + number + number + number + + getPageUrlsForAIAssistant + index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=BotTracking&apiAction=getAIAssistantRequests&period=day&date=2009-01-04 + index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=BotTracking&apiAction=getAIAssistantRequests&period=day&date=2008-12-06,2009-01-04 + BotTracking_getAIAssistantRequests + UI Framework Data tables @@ -3832,6 +3909,13 @@ Unique Human Visitors Human Users Maximum Actions in one Human Visit + Requests + Acquired visits + Unique page URLs + Not found requests + Unique AI chatbots + Unique document URLs + Server error (5xx) requests Bytes transferred overall Bytes transferred pageviews Bytes transferred downloads @@ -3857,11 +3941,19 @@ Average time (in seconds) it takes for the browser to load media and execute any Javascript code listening for the DOMContentLoaded event after the webpage was loaded and the user can already interact with it. Average time (in seconds) it takes the browser to execute Javascript code waiting for the window.load event. This event is triggered once the DOM has completely rendered. Average time (in seconds) it takes from requesting a page until the page is fully rendered within the browser + Total number of bot requests recorded during the selected period. Includes both page and document URLs. + Visits that started after someone clicked from an AI assistant (Referrers → AI Assistants). + Distinct page URLs requested by AI assistants. + Number of bot requests that returned an HTTP 404 or 410 response, including document URLs. + AI assistant user agents detected during the selected period. + Distinct download or document URLs requested by AI assistants. + Number of bot requests that returned an HTTP 5xx response, including document URLs. The average time it took to generate the page. This metric includes the time it took the server to generate the web page, plus the time it took for the visitor to download the response from the server. A lower 'Avg. generation time' means a faster website for your visitors! The percentage of visits that triggered a conversion. The conversion rate is calculated using the number of visits that converted at least one goal. Visits converting multiple goals are only counted once in the conversion rate. The percentage of visits that only had a single pageview. This means, that the visitor left the website directly from the entrance page. The average number of actions (page views, site searches, downloads or outlinks) that were performed during the visits. The average duration of a visit. + Share of AI assistant requests that resulted in acquired visits (acquired visits ÷ requests). Avg. generation time @@ -3888,6 +3980,7 @@ Avg. Duration of a Human Visit (in sec) Avg. Actions per Human Visit Bounce Rate for Human Visits + Click-through rate Bytes transferred downloads Bytes transferred pageviews Bytes transferred overall @@ -3955,6 +4048,13 @@ number number number + number + number + number + number + number + number + number number number number @@ -3975,6 +4075,7 @@ duration_s number percent + percent index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=API&apiAction=get&period=day&date=2008-12-06,2009-01-04 index.php?module=API&method=ImageGraph.get&idSite=1&apiModule=API&apiAction=get&period=day&date=2008-12-06,2009-01-04 diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportPagesMetadata.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportPagesMetadata.xml index 1c02b485649..e4b505080d1 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportPagesMetadata.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getReportPagesMetadata.xml @@ -51,6 +51,71 @@ + + General_AIAssistants.BotTracking_AIBotsOverview + + General_AIAssistants + AI Assistants + 80 + icon-admin-platform + + + + + BotTracking_AIBotsOverview + AI Bots Overview + 20 + <p>The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.</p><p>It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.</p><p>Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.</p> + + + + AI Assistants - Bots Over Time + BotTracking + getEvolutionGraph + 1 + + 1 + graphEvolution + BotTracking + getEvolutionGraph + + widgetBotTrackinggetEvolutionGraphforceView1viewDataTablegraphEvolution + 0 + graphEvolution + 1 + + + AI Assistants - Bots Overview + BotTracking + get + 2 + + 1 + sparklines + BotTracking + get + + widgetBotTrackinggetforceView1viewDataTablesparklines + 0 + sparklines + 1 + + + AI Assistants - Bots + BotTracking + getAIAssistantRequests + 130 + + BotTracking + getAIAssistantRequests + + widgetBotTrackinggetAIAssistantRequests + 1 + table + 1 + + + General_Actions.VisitorInterest_Engagement diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getWidgetMetadata.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getWidgetMetadata.xml index 2fd3456b920..ae98987a9e3 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getWidgetMetadata.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.getWidgetMetadata.xml @@ -3708,6 +3708,94 @@ sparklines 1 + + AI Assistants - Bots + + General_AIAssistants + AI Assistants + 80 + icon-admin-platform + + + + + BotTracking_AIBotsOverview + AI Bots Overview + 20 + <p>The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.</p><p>It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.</p><p>Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.</p> + + BotTracking + getAIAssistantRequests + 130 + + BotTracking + getAIAssistantRequests + + widgetBotTrackinggetAIAssistantRequests + 1 + table + 1 + + + AI Assistants - Bots Over Time + + General_AIAssistants + AI Assistants + 80 + icon-admin-platform + + + + + BotTracking_AIBotsOverview + AI Bots Overview + 20 + <p>The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.</p><p>It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.</p><p>Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.</p> + + BotTracking + getEvolutionGraph + 1 + + 1 + graphEvolution + BotTracking + getEvolutionGraph + + widgetBotTrackinggetEvolutionGraphforceView1viewDataTablegraphEvolution + 0 + graphEvolution + 1 + + + AI Assistants - Bots Overview + + General_AIAssistants + AI Assistants + 80 + icon-admin-platform + + + + + BotTracking_AIBotsOverview + AI Bots Overview + 20 + <p>The AI Assistant Overview page provide insights into website traffic originating from AI Assistants such as ChatGPT and other large language model–based assistants. These reports track key metrics including the number of requests made by these bots, the pages and documents they access, and any errors encountered. They also offer detailed breakdowns showing which bots visit specific page URLs, helping you understand how AI assistants interact with your content and identify opportunities to improve visibility and accessibility for AI-driven users.</p><p>It’s important to note that none of these pages were actually viewed by humans in the traditional way — all requests originate from AI assistants fetching content automatically.</p><p>Currently, these reports exclusively include requests from AI bots that do not execute JavaScript. They do not include traffic from AI crawlers used for training AI models or from AI agents capable of executing JavaScript.</p> + + BotTracking + get + 2 + + 1 + sparklines + BotTracking + get + + widgetBotTrackinggetforceView1viewDataTablesparklines + 0 + sparklines + 1 + Pie graph diff --git a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.get_day.xml b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.get_day.xml index 570721eef78..f02a6868509 100644 --- a/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.get_day.xml +++ b/tests/PHPUnit/System/expected/test_apiGetReportMetadata__API.get_day.xml @@ -69,6 +69,13 @@ 0 0 0% + 0 + 0 + 0 + 0 + 0 + 0 + 0 0 0 0 @@ -106,4 +113,5 @@ 100% 1 1086 + 0 \ No newline at end of file diff --git a/tests/PHPUnit/Unit/DataTableTest.php b/tests/PHPUnit/Unit/DataTableTest.php index d736afa1d37..f77029f3cb7 100644 --- a/tests/PHPUnit/Unit/DataTableTest.php +++ b/tests/PHPUnit/Unit/DataTableTest.php @@ -24,47 +24,47 @@ */ class DataTableTest extends \PHPUnit\Framework\TestCase { - public function testApplyFilter() + public function testApplyFilter(): void { $table = $this->getDataTable1ForTest(); - $this->assertEquals(4, $table->getRowsCount()); - $table->filter('Limit', array(2, 2)); - $this->assertEquals(2, $table->getRowsCount()); - $table->filter('Limit', array(0, 1)); - $this->assertEquals(1, $table->getRowsCount()); + self::assertEquals(4, $table->getRowsCount()); + $table->filter('Limit', [2, 2]); + self::assertEquals(2, $table->getRowsCount()); + $table->filter('Limit', [0, 1]); + self::assertEquals(1, $table->getRowsCount()); } - protected function getSimpleTestDataTable() + protected function getSimpleTestDataTable(): DataTable { $table = new DataTable(); $table->addRowsFromArray( - array( - array(Row::COLUMNS => array('label' => 'ten', 'count' => 10)), - array(Row::COLUMNS => array('label' => 'ninety', 'count' => 90)), - array(Row::COLUMNS => array('label' => 'hundred', 'count' => 100)), - DataTable::ID_SUMMARY_ROW => array(Row::COLUMNS => array('label' => 'summary', 'count' => 200)), - ) + [ + [Row::COLUMNS => ['label' => 'ten', 'count' => 10]], + [Row::COLUMNS => ['label' => 'ninety', 'count' => 90]], + [Row::COLUMNS => ['label' => 'hundred', 'count' => 100]], + DataTable::ID_SUMMARY_ROW => [Row::COLUMNS => ['label' => 'summary', 'count' => 200]], + ] ); - $table->setTotalsRow(new Row(array(Row::COLUMNS => array('label' => 'Total', 'count' => 200)))); + $table->setTotalsRow(new Row([Row::COLUMNS => ['label' => 'Total', 'count' => 200]])); return $table; } - protected function getSimpleTestDataTable2() + protected function getSimpleTestDataTable2(): DataTable { $table = new DataTable(); $table->addRowsFromArray( - array( - array(Row::COLUMNS => array('label' => 'ten', 'count' => 10)), - array(Row::COLUMNS => array('label' => 'ninety', 'count' => 20)), - array(Row::COLUMNS => array('label' => 'hundred', 'count' => 30)), - DataTable::ID_SUMMARY_ROW => array(Row::COLUMNS => array('label' => 'summary', 'count' => 60)), - ) + [ + [Row::COLUMNS => ['label' => 'ten', 'count' => 10]], + [Row::COLUMNS => ['label' => 'ninety', 'count' => 20]], + [Row::COLUMNS => ['label' => 'hundred', 'count' => 30]], + DataTable::ID_SUMMARY_ROW => [Row::COLUMNS => ['label' => 'summary', 'count' => 60]], + ] ); - $table->setTotalsRow(new Row(array(Row::COLUMNS => array('label' => 'Total', 'count' => 60)))); + $table->setTotalsRow(new Row([Row::COLUMNS => ['label' => 'Total', 'count' => 60]])); return $table; } - public function testMultiFilter() + public function testMultiFilter(): void { $table = $this->getSimpleTestDataTable(); $table2 = $this->getSimpleTestDataTable2(); @@ -77,146 +77,198 @@ public function testMultiFilter() $tableExpected = new DataTable(); $tableExpected->addRowsFromArray( [ - array(Row::COLUMNS => array('label' => 'ten', 'count' => 20)), - array(Row::COLUMNS => array('label' => 'ninety', 'count' => 110)), - array(Row::COLUMNS => array('label' => 'hundred', 'count' => 130)), - DataTable::ID_SUMMARY_ROW => array(Row::COLUMNS => array('label' => 'summary', 'count' => 260)), + [Row::COLUMNS => ['label' => 'ten', 'count' => 20]], + [Row::COLUMNS => ['label' => 'ninety', 'count' => 110]], + [Row::COLUMNS => ['label' => 'hundred', 'count' => 130]], + DataTable::ID_SUMMARY_ROW => [Row::COLUMNS => ['label' => 'summary', 'count' => 260]], ] ); - $this->assertEquals(5, $result); - $this->assertEquals($tableExpected->getRows(), $table->getRows()); + self::assertEquals(5, $result); + self::assertEquals($tableExpected->getRows(), $table->getRows()); } - public function testRenameColumn() + public function testRenameColumn(): void { $table = $this->getSimpleTestDataTable(); - $this->assertEquals(array(10, 90, 100, 200), $table->getColumn('count')); - $this->assertEquals(200, $table->getTotalsRow()->getColumn('count')); + self::assertEquals([10, 90, 100, 200], $table->getColumn('count')); + self::assertEquals(200, $table->getTotalsRow()->getColumn('count')); $table->renameColumn('count', 'renamed'); - $this->assertEquals(array(false, false, false, false), $table->getColumn('count')); - $this->assertEquals(array(10, 90, 100, 200), $table->getColumn('renamed')); - $this->assertEquals(200, $table->getTotalsRow()->getColumn('renamed')); + self::assertEquals([false, false, false, false], $table->getColumn('count')); + self::assertEquals([10, 90, 100, 200], $table->getColumn('renamed')); + self::assertEquals(200, $table->getTotalsRow()->getColumn('renamed')); } - public function testDeleteColumn() + public function testDeleteColumn(): void { $table = $this->getSimpleTestDataTable(); - $this->assertEquals(array(10, 90, 100, 200), $table->getColumn('count')); + self::assertEquals([10, 90, 100, 200], $table->getColumn('count')); $table->deleteColumn('count'); - $this->assertEquals(array(false, false, false, false), $table->getColumn('count')); - $this->assertEquals(false, $table->getTotalsRow()->getColumn('count')); + self::assertEquals([false, false, false, false], $table->getColumn('count')); + self::assertFalse($table->getTotalsRow()->getColumn('count')); } - public function testDeleteRow() + public function testDeleteRow(): void { $table = $this->getSimpleTestDataTable(); // normal row $idToDelete = 1; - $this->assertEquals(2, count($table->getRowFromId($idToDelete)->getColumns())); + self::assertEquals(2, count($table->getRowFromId($idToDelete)->getColumns())); $table->deleteRow($idToDelete); - $this->assertFalse($table->getRowFromId($idToDelete)); + self::assertFalse($table->getRowFromId($idToDelete)); // summary row special case $idToDelete = DataTable::ID_SUMMARY_ROW; - $this->assertEquals(2, count($table->getRowFromId($idToDelete)->getColumns())); + self::assertEquals(2, count($table->getRowFromId($idToDelete)->getColumns())); $table->deleteRow($idToDelete); - $this->assertFalse($table->getRowFromId($idToDelete)); + self::assertFalse($table->getRowFromId($idToDelete)); } - public function testGetLastRow() + public function testGetLastRow(): void { $table = $this->getSimpleTestDataTable(); $rowsCount = $table->getRowsCount(); - $this->assertEquals($table->getLastRow(), $table->getRowFromId(DataTable::ID_SUMMARY_ROW)); + self::assertEquals($table->getLastRow(), $table->getRowFromId(DataTable::ID_SUMMARY_ROW)); $table->deleteRow(DataTable::ID_SUMMARY_ROW); - $this->assertEquals($table->getLastRow(), $table->getRowFromId($rowsCount - 2)); + self::assertEquals($table->getLastRow(), $table->getRowFromId($rowsCount - 2)); } - public function testGetRowFromIdSubDataTable() + public function testGetRowFromIdSubDataTable(): void { $table1 = $this->getDataTable1ForTest(); $idTable1 = $table1->getId(); $table2 = $this->getDataTable2ForTest(); - $this->assertFalse($table2->getRowFromIdSubDataTable($idTable1)); + self::assertFalse($table2->getRowFromIdSubDataTable($idTable1)); $table2->getFirstRow()->setSubtable($table1); - $this->assertEquals($table2->getRowFromIdSubDataTable($idTable1), $table2->getFirstRow()); + self::assertEquals($table2->getRowFromIdSubDataTable($idTable1), $table2->getFirstRow()); $table3 = $this->getDataTable1ForTest(); $idTable3 = $table3->getId(); $table2->getLastRow()->setSubtable($table3); - $this->assertEquals($table2->getRowFromIdSubDataTable($idTable3), $table2->getLastRow()); + self::assertEquals($table2->getRowFromIdSubDataTable($idTable3), $table2->getLastRow()); } - public function testRebuildIndex() + public function testRebuildIndex(): void { - $labels = array(0 => 'abc', 1 => 'def', 2 => 'ghi', 3 => 'jkl', 4 => 'mno'); - $table = new DataTable(); + $labels = [0 => 'abc', 1 => 'def', 2 => 'ghi', 3 => 'jkl', 4 => 'mno']; + $table = new DataTable(); - $rows = array(); + $rows = []; foreach ($labels as $label) { - $row = new Row(array(Row::COLUMNS => array('label' => $label))); + $row = new Row([Row::COLUMNS => ['label' => $label]]); $table->addRow($row); $rows[] = $row; } foreach ($labels as $label) { $rowVerify1 = $table->getRowFromLabel($label); - $this->assertSame($label, $rowVerify1->getColumn('label')); + self::assertSame($label, $rowVerify1->getColumn('label')); } - $table->setRows(array($rows[2], $rows[3], $rows[4])); + $table->setRows([$rows[2], $rows[3], $rows[4]]); $table->rebuildIndex();// rebuildindex would be called anyway but we force rebuilding the index just to make sure // verify still accessible $rowVerify1 = $table->getRowFromLabel('ghi'); - $this->assertSame('ghi', $rowVerify1->getColumn('label')); + self::assertSame('ghi', $rowVerify1->getColumn('label')); // verify no longer accessible $rowVerify3 = $table->getRowFromLabel('abc'); - $this->assertFalse($rowVerify3); + self::assertFalse($rowVerify3); } - public function testCloneShouldIncreasesTableId() + public function testCloneShouldIncreasesTableId(): void { $table = new DataTable(); - $rows = array( - array(Row::COLUMNS => array('label' => 'google')), - ); + $rows = [ + [Row::COLUMNS => ['label' => 'google']], + ]; $table->addRowsFromArray($rows); $table2 = clone $table; - $this->assertSame($table2->getId(), $table->getId() + 1); + self::assertSame($table2->getId(), $table->getId() + 1); } /** * we test the count rows and the count rows recursive version * on a Simple array (1 level only) */ - public function testCountRowsSimple() + public function testCountRowsSimple(): void { $table = new DataTable(); $idcol = Row::COLUMNS; - $rows = array( - array($idcol => array('label' => 'google')), - array($idcol => array('label' => 'ask')), - array($idcol => array('label' => 'piwik')), - array($idcol => array('label' => 'yahoo')), - array($idcol => array('label' => 'amazon')), - array($idcol => array('label' => '238975247578949')), - array($idcol => array('label' => 'Q*(%&*("$&%*(&"$*")"))')), - ); + $rows = [ + [$idcol => ['label' => 'google']], + [$idcol => ['label' => 'ask']], + [$idcol => ['label' => 'piwik']], + [$idcol => ['label' => 'yahoo']], + [$idcol => ['label' => 'amazon']], + [$idcol => ['label' => '238975247578949']], + [$idcol => ['label' => 'Q*(%&*("$&%*(&"$*")"))']], + ]; $table->addRowsFromArray($rows); - $this->assertEquals(7, $table->getRowsCount()); - $this->assertEquals(7, $table->getRowsCountRecursive()); + self::assertEquals(7, $table->getRowsCount()); + self::assertEquals(7, $table->getRowsCountRecursive()); + self::assertEquals(7, $table->getLeafRowsCount()); + } + + public function testGetLeafRowsCountReturnsZeroForEmptyTable(): void + { + $table = new DataTable(); + + self::assertSame(0, $table->getLeafRowsCount()); + } + + public function testGetLeafRowsCountIgnoresSummaryRow(): void + { + $table = new DataTable(); + + $rowWithSubtable = new Row([Row::COLUMNS => ['label' => 'parent']]); + + $subTable = new DataTable(); + $subTable->addRow(new Row([Row::COLUMNS => ['label' => 'child1']])); + $subTable->addRow(new Row([Row::COLUMNS => ['label' => 'child2']])); + $rowWithSubtable->setSubtable($subTable); + + $table->addRow($rowWithSubtable); + $table->addRow(new Row([Row::COLUMNS => ['label' => 'sibling']])); + $table->addSummaryRow(new Row([Row::COLUMNS => ['label' => 'summary']])); + + self::assertSame(3, $table->getLeafRowsCount()); + } + + public function testGetLeafRowsCountHandlesDeepHierarchy(): void + { + $deepestSubtable = new DataTable(); + $deepestSubtable->addRow(new Row([Row::COLUMNS => ['label' => 'greatgrandchild']])); + + $grandchildTable = new DataTable(); + $grandchildTable->addRow(new Row([Row::COLUMNS => ['label' => 'grandchild1']])); + $grandchildWithSubtable = new Row([Row::COLUMNS => ['label' => 'grandchild2']]); // not a leaf because it has a subtable + $grandchildWithSubtable->setSubtable($deepestSubtable); + $grandchildTable->addRow($grandchildWithSubtable); + + $childTable = new DataTable(); + $childWithSubtable = new Row([Row::COLUMNS => ['label' => 'child-with-deeper']]); + $childWithSubtable->setSubtable($grandchildTable); + $childTable->addRow($childWithSubtable); + $childTable->addRow(new Row([Row::COLUMNS => ['label' => 'child-leaf']])); + + $table = new DataTable(); + $parent = new Row([Row::COLUMNS => ['label' => 'parent']]); + $parent->setSubtable($childTable); + $table->addRow($parent); + $table->addRow(new Row([Row::COLUMNS => ['label' => 'root-leaf']])); + + self::assertSame(4, $table->getLeafRowsCount()); } /** @@ -227,275 +279,297 @@ public function testCountRowsSimple() * the sum of the number of rows of all the subtables * + the number of rows in the parent table */ - public function testCountRowsComplex() + public function testCountRowsComplex(): void { - $idcol = Row::COLUMNS; + $idcol = Row::COLUMNS; $idsubtable = Row::DATATABLE_ASSOCIATED; // table to go in the SUB table of RoW1 $tableSubOfSubOfRow1 = new DataTable(); - $rows1sub = array( - array($idcol => array('label' => 'google')), - array($idcol => array('label' => 'google78')), - array($idcol => array('label' => 'googlaegge')), - array($idcol => array('label' => 'gogeoggle')), - array($idcol => array('label' => 'goaegaegaogle')), - array($idcol => array('label' => 'ask')), - array($idcol => array('label' => '238975247578949')), - ); + $rows1sub = [ + [$idcol => ['label' => 'google']], + [$idcol => ['label' => 'google78']], + [$idcol => ['label' => 'googlaegge']], + [$idcol => ['label' => 'gogeoggle']], + [$idcol => ['label' => 'goaegaegaogle']], + [$idcol => ['label' => 'ask']], + [$idcol => ['label' => '238975247578949']], + ]; $tableSubOfSubOfRow1->addRowsFromArray($rows1sub); // table to go in row1 $tableSubOfRow1 = new DataTable(); - $rows1 = array( - array($idcol => array('label' => 'google'), $idsubtable => $tableSubOfSubOfRow1), - array($idcol => array('label' => 'ask')), - array($idcol => array('label' => '238975247578949')), - ); + $rows1 = [ + [$idcol => ['label' => 'google'], $idsubtable => $tableSubOfSubOfRow1], + [$idcol => ['label' => 'ask']], + [$idcol => ['label' => '238975247578949']], + ]; $tableSubOfRow1->addRowsFromArray($rows1); // table to go in row2 $tableSubOfRow2 = new DataTable(); - $rows2 = array( - array($idcol => array('label' => 'google')), - array($idcol => array('label' => 'ask')), - array($idcol => array('label' => '238975247578949')), - array($idcol => array('label' => 'agaegaesk')), - array($idcol => array('label' => '23g 8975247578949')), - ); + $rows2 = [ + [$idcol => ['label' => 'google']], + [$idcol => ['label' => 'ask']], + [$idcol => ['label' => '238975247578949']], + [$idcol => ['label' => 'agaegaesk']], + [$idcol => ['label' => '23g 8975247578949']], + ]; $tableSubOfRow2->addRowsFromArray($rows2); // main parent table $table = new DataTable(); - $rows = array( - array($idcol => array('label' => 'row1')), - array($idcol => array('label' => 'row2'), - $idsubtable => $tableSubOfRow1), - array($idcol => array('label' => 'row3'), - $idsubtable => $tableSubOfRow2), - ); + $rows = [ + [$idcol => ['label' => 'row1']], + [ + $idcol => ['label' => 'row2'], + $idsubtable => $tableSubOfRow1, + ], + [ + $idcol => ['label' => 'row3'], + $idsubtable => $tableSubOfRow2, + ], + ]; $table->addRowsFromArray($rows); - $this->assertEquals(3, $table->getRowsCount()); - $this->assertEquals(18, $table->getRowsCountRecursive()); + self::assertEquals(3, $table->getRowsCount()); + self::assertEquals(18, $table->getRowsCountRecursive()); + self::assertEquals(15, $table->getLeafRowsCount()); } /** * Simple test of the DataTable_Row */ - public function testRow() + public function testRow(): void { - $columns = array('test_column' => 145, - 92582495 => new Timer(), - 'super' => array('this column has an array value, amazing')); - $metadata = array('logo' => 'piwik.png', - 'super' => array('this column has an array value, amazing')); - $arrayRow = array( - Row::COLUMNS => $columns, - Row::METADATA => $metadata, - 'fake useless key' => 38959, - 43905724897 => 'value'); - $row = new Row($arrayRow); - - $this->assertEquals($columns, $row->getColumns()); - $this->assertEquals($metadata, $row->getMetadata()); - $this->assertNull($row->getIdSubDataTable()); + $columns = [ + 'test_column' => 145, + 92582495 => new Timer(), + 'super' => ['this column has an array value, amazing'], + ]; + $metadata = [ + 'logo' => 'piwik.png', + 'super' => ['this column has an array value, amazing'], + ]; + $arrayRow = [ + Row::COLUMNS => $columns, + Row::METADATA => $metadata, + 'fake useless key' => 38959, + 43905724897 => 'value', + ]; + $row = new Row($arrayRow); + + self::assertEquals($columns, $row->getColumns()); + self::assertEquals($metadata, $row->getMetadata()); + self::assertNull($row->getIdSubDataTable()); } /** * Simple test of the DataTable_Row */ - public function testSumRow() + public function testSumRow(): void { - $columns = array('test_int' => 145, - 'test_float' => 145.5, - 'test_float3' => 1.5, - 'test_stringint' => "145", - "test" => 'string fake', - 'integerArrayToSum' => array(1 => 1, 2 => 10.0, 3 => array(1 => 2, 2 => 3)), - ); - $metadata = array('logo' => 'piwik.png', - 'super' => array('this column has an array value, amazing')); - $arrayRow = array( - Row::COLUMNS => $columns, - Row::METADATA => $metadata, - 'fake useless key' => 38959, - '43905724897' => 'value'); - $row1 = new Row($arrayRow); - - $columns2 = array('test_int' => 5, - 'test_float' => 4.5, - 'test_float2' => 14.5, - 'test_stringint' => "5", - 925824 => 'toto', - 'integerArrayToSum' => array(1 => 5, 2 => 5.5, 3 => array(2 => 4)), - ); - $finalRow = new Row(array(Row::COLUMNS => $columns2)); + $columns = [ + 'test_int' => 145, + 'test_float' => 145.5, + 'test_float3' => 1.5, + 'test_stringint' => "145", + "test" => 'string fake', + 'integerArrayToSum' => [1 => 1, 2 => 10.0, 3 => [1 => 2, 2 => 3]], + ]; + $metadata = [ + 'logo' => 'piwik.png', + 'super' => ['this column has an array value, amazing'], + ]; + $arrayRow = [ + Row::COLUMNS => $columns, + Row::METADATA => $metadata, + 'fake useless key' => 38959, + '43905724897' => 'value', + ]; + $row1 = new Row($arrayRow); + + $columns2 = [ + 'test_int' => 5, + 'test_float' => 4.5, + 'test_float2' => 14.5, + 'test_stringint' => "5", + 925824 => 'toto', + 'integerArrayToSum' => [1 => 5, 2 => 5.5, 3 => [2 => 4]], + ]; + $finalRow = new Row([Row::COLUMNS => $columns2]); $finalRow->sumRow($row1); - $columnsWanted = array('test_int' => 150, - 'test_float' => 150.0, - 'test_float2' => 14.5, - 'test_float3' => 1.5, - 'test_stringint' => 150, //add also strings!! - 'test' => 'string fake', - 'integerArrayToSum' => array(1 => 6, 2 => 15.5, 3 => array(1 => 2, 2 => 7)), - 925824 => 'toto', - ); + $columnsWanted = [ + 'test_int' => 150, + 'test_float' => 150.0, + 'test_float2' => 14.5, + 'test_float3' => 1.5, + 'test_stringint' => 150, //add also strings!! + 'test' => 'string fake', + 'integerArrayToSum' => [1 => 6, 2 => 15.5, 3 => [1 => 2, 2 => 7]], + 925824 => 'toto', + ]; // Also testing that metadata is copied over - $rowWanted = new Row(array(Row::COLUMNS => $columnsWanted, Row::METADATA => $metadata)); - $this->assertTrue(Row::isEqual($rowWanted, $finalRow)); + $rowWanted = new Row([Row::COLUMNS => $columnsWanted, Row::METADATA => $metadata]); + self::assertTrue(Row::isEqual($rowWanted, $finalRow)); // testing that, 'sumRow' does not result in extra unwanted attributes being serialized $expectedRow = 'a:3:{i:0;a:8:{s:8:"test_int";i:150;s:10:"test_float";d:150;s:11:"test_float2";d:14.5;s:14:"test_stringint";i:150;i:925824;s:4:"toto";s:17:"integerArrayToSum";a:3:{i:1;i:6;i:2;d:15.5;i:3;a:2:{i:2;i:7;i:1;i:2;}}s:11:"test_float3";d:1.5;s:4:"test";s:11:"string fake";}i:1;a:2:{s:4:"logo";s:9:"piwik.png";s:5:"super";a:1:{i:0;s:39:"this column has an array value, amazing";}}i:3;N;}'; - $this->assertEquals($expectedRow, serialize($finalRow->export())); + self::assertEquals($expectedRow, serialize($finalRow->export())); // Testing sumRow with disabled metadata sum - $rowWanted = new Row(array(Row::COLUMNS => $columnsWanted)); // no metadata - $finalRow = new Row(array(Row::COLUMNS => $columns2)); + $rowWanted = new Row([Row::COLUMNS => $columnsWanted]); // no metadata + $finalRow = new Row([Row::COLUMNS => $columns2]); $finalRow->sumRow($row1, $enableCopyMetadata = false); - $this->assertTrue(Row::isEqual($rowWanted, $finalRow)); + self::assertTrue(Row::isEqual($rowWanted, $finalRow)); } /** * @dataProvider unserializeTestsDataProvider */ - public function testUnserializeWorksWithAllDataTableFormats($indexToRead, $label, $column2, $subtable) + public function testUnserializeWorksWithAllDataTableFormats($indexToRead, $label, $column2, $subtable): void { - $serializedDatatable = array(); + $serializedDatatable = []; // Prior Piwik 2.13, we serialized the actual Row or DataTableSummaryRow instances, afterwards only arrays require PIWIK_INCLUDE_PATH . "/tests/resources/DataTables-archived-different-formats.php"; require_once PIWIK_INCLUDE_PATH . "/core/DataTable/Bridges.php"; $table = $serializedDatatable[$indexToRead]; - $this->assertTrue(strlen($table) > 1000); + self::assertTrue(strlen($table) > 1000); $table = DataTable::fromSerializedArray($table); $row1 = $table->getFirstRow(); - $this->assertTrue($row1 instanceof \Piwik\DataTable\Row); - $this->assertFalse($row1 instanceof \Piwik\DataTable\Row\DataTableSummaryRow); // we convert summary rows to Row instances + self::assertTrue($row1 instanceof \Piwik\DataTable\Row); + self::assertFalse($row1 instanceof \Piwik\DataTable\Row\DataTableSummaryRow); // we convert summary rows to Row instances - $this->assertEquals($label, $row1->getColumn('label')); - $this->assertEquals($column2, $row1->getColumn(2)); - $this->assertEquals($subtable, $row1->getIdSubDataTable()); + self::assertEquals($label, $row1->getColumn('label')); + self::assertEquals($column2, $row1->getColumn(2)); + self::assertEquals($subtable, $row1->getIdSubDataTable()); } - public function testSumRowMetadataCustomAggregationOperation() + public function testSumRowMetadataCustomAggregationOperation(): void { - $metadata1 = array('mytest' => 'value1'); - $metadata2 = array('mytest' => 'value2'); - - $self = $this; - $row1 = new Row(array(Row::COLUMNS => array('test_int' => 145), Row::METADATA => $metadata1)); - $finalRow = new Row(array(Row::COLUMNS => array('test_int' => 5), Row::METADATA => $metadata2)); - $finalRow->sumRowMetadata($row1, array('mytest' => function ($thisValue, $otherValue, $thisRow, $otherRow) use ($self, $row1, $finalRow) { - $self->assertEquals('value2', $thisValue); - $self->assertEquals('value1', $otherValue); - $self->assertSame($thisRow, $finalRow); - $self->assertSame($otherRow, $row1); - - if (!is_array($thisValue)) { - $thisValue = array($thisValue); - } + $metadata1 = ['mytest' => 'value1']; + $metadata2 = ['mytest' => 'value2']; + + $self = $this; + $row1 = new Row([Row::COLUMNS => ['test_int' => 145], Row::METADATA => $metadata1]); + $finalRow = new Row([Row::COLUMNS => ['test_int' => 5], Row::METADATA => $metadata2]); + $finalRow->sumRowMetadata($row1, [ + 'mytest' => function ($thisValue, $otherValue, $thisRow, $otherRow) use ($self, $row1, $finalRow) { + $self->assertEquals('value2', $thisValue); + $self->assertEquals('value1', $otherValue); + $self->assertSame($thisRow, $finalRow); + $self->assertSame($otherRow, $row1); + + if (!is_array($thisValue)) { + $thisValue = [$thisValue]; + } - $thisValue[] = $otherValue; - return $thisValue; - })); + $thisValue[] = $otherValue; + return $thisValue; + }, + ]); - $this->assertEquals(array('value2', 'value1'), $finalRow->getMetadata('mytest')); + self::assertEquals(['value2', 'value1'], $finalRow->getMetadata('mytest')); } - public function testSumRowCustomAggregationOperation() + public function testSumRowCustomAggregationOperation(): void { - $columns = array('test_int' => 145, 'test_float' => 145.5); + $columns = ['test_int' => 145, 'test_float' => 145.5]; - $row1 = new Row(array(Row::COLUMNS => $columns)); + $row1 = new Row([Row::COLUMNS => $columns]); - $columns2 = array('test_int' => 5); - $finalRow = new Row(array(Row::COLUMNS => $columns2)); + $columns2 = ['test_int' => 5]; + $finalRow = new Row([Row::COLUMNS => $columns2]); $self = $this; - $finalRow->sumRow($row1, $copyMetadata = true, $operation = array('test_int' => function ($thisValue, $otherValue, $thisRow, $otherRow) use ($self, $row1, $finalRow) { - $self->assertEquals(5, $thisValue); - $self->assertEquals(145, $otherValue); - $self->assertSame($thisRow, $finalRow); - $self->assertSame($otherRow, $row1); + $finalRow->sumRow($row1, $copyMetadata = true, $operation = [ + 'test_int' => function ($thisValue, $otherValue, $thisRow, $otherRow) use ($self, $row1, $finalRow) { + $self->assertEquals(5, $thisValue); + $self->assertEquals(145, $otherValue); + $self->assertSame($thisRow, $finalRow); + $self->assertSame($otherRow, $row1); - if (!is_array($thisValue)) { - $thisValue = array($thisValue); - } + if (!is_array($thisValue)) { + $thisValue = [$thisValue]; + } - $thisValue[] = $otherValue; - return $thisValue; - })); + $thisValue[] = $otherValue; + return $thisValue; + }, + ]); - $this->assertEquals(array(5, 145), $finalRow->getColumn('test_int')); + self::assertEquals([5, 145], $finalRow->getColumn('test_int')); } - public function testSumRowShouldThrowExceptionIfInvalidOperationIsGiven() + public function testSumRowShouldThrowExceptionIfInvalidOperationIsGiven(): void { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Unknown operation \'foobarinvalid\''); + self::expectException(\Exception::class); + self::expectExceptionMessage('Unknown operation \'foobarinvalid\''); - $row1 = new Row(array(Row::COLUMNS => array('test_int' => 145))); - $finalRow = new Row(array(Row::COLUMNS => array('test_int' => 5))); - $finalRow->sumRow($row1, $copyMetadata = true, $operation = array('test_int' => 'fooBarInvalid')); + $row1 = new Row([Row::COLUMNS => ['test_int' => 145]]); + $finalRow = new Row([Row::COLUMNS => ['test_int' => 5]]); + $finalRow->sumRow($row1, $copyMetadata = true, $operation = ['test_int' => 'fooBarInvalid']); - $this->assertEquals(array(5, 145), $finalRow->getColumn('test_int')); + self::assertEquals([5, 145], $finalRow->getColumn('test_int')); } - public function unserializeTestsDataProvider() + public function unserializeTestsDataProvider(): array { - return array( - array($index = 0, $label = 'piwik.org', $column2 = 10509, $idSubtable = 1581), // pre Piwik 2.0 (without namespaces, Piwik_DataTable_Row) - array($index = 1, $label = 'piwikactions.org', $column2 = 10508, $idSubtable = 1581), // pre Piwik 2.0 Actions (without namespaces, Piwik_DataTable_Row_DataTableSummary) - array($index = 2, $label = 'start', $column2 = 89, $idSubtable = 2260), // >= Piwik 2.0 < Piwik 2.13 Actions (DataTableSummaryRow) - array($index = 3, $label = 'Ask', $column2 = 11, $idSubtable = 3335), // >= Piwik 2.0 < Piwik 2.13 Referrers (Row) - array($index = 4, $label = 'MyLabel Test', $column2 = 447, $idSubtable = 1), // >= Piwik 2.0 < Piwik 2.13 Referrers (Row) - ); + return [ + [$index = 0, $label = 'piwik.org', $column2 = 10509, $idSubtable = 1581], // pre Piwik 2.0 (without namespaces, Piwik_DataTable_Row) + [$index = 1, $label = 'piwikactions.org', $column2 = 10508, $idSubtable = 1581], // pre Piwik 2.0 Actions (without namespaces, Piwik_DataTable_Row_DataTableSummary) + [$index = 2, $label = 'start', $column2 = 89, $idSubtable = 2260], // >= Piwik 2.0 < Piwik 2.13 Actions (DataTableSummaryRow) + [$index = 3, $label = 'Ask', $column2 = 11, $idSubtable = 3335], // >= Piwik 2.0 < Piwik 2.13 Referrers (Row) + [$index = 4, $label = 'MyLabel Test', $column2 = 447, $idSubtable = 1], // >= Piwik 2.0 < Piwik 2.13 Referrers (Row) + ]; } /** - * Test that adding two string column values results in an exception. + * Test that adding two string column values not results in an exception. */ - public function testSumRowStringException() + public function testSumRowStringDoesNotThrowException(): void { - $columns = array( - 'super' => array('this column has an array string that will be 0 when algorithm sums the value'), - ); - $row1 = new Row(array(Row::COLUMNS => $columns)); + self::expectNotToPerformAssertions(); + $columns = [ + 'super' => ['this column has an array string that will be 0 when algorithm sums the value'], + ]; + $row1 = new Row([Row::COLUMNS => $columns]); - $columns2 = array( - 'super' => array('this column has geagaean array value, amazing'), - ); - $row2 = new Row(array(Row::COLUMNS => $columns2)); + $columns2 = [ + 'super' => ['this column has geagaean array value, amazing'], + ]; + $row2 = new Row([Row::COLUMNS => $columns2]); $row2->sumRow($row1); - $this->assertTrue($noException = true); } /** * Test serialize with an infinite recursion (a row linked to a table in the parent hierarchy) * After 100 recursion must throw an exception */ - public function testSerializeWithInfiniteRecursion() + public function testSerializeWithInfiniteRecursion(): void { - $this->expectException(\Exception::class); + self::expectException(\Exception::class); $table = new DataTable(); - $table->addRowFromArray(array(Row::COLUMNS => array('visits' => 245, 'visitors' => 245), - Row::DATATABLE_ASSOCIATED => $table)); + $table->addRowFromArray([ + Row::COLUMNS => ['visits' => 245, 'visitors' => 245], + Row::DATATABLE_ASSOCIATED => $table, + ]); $table->getSerialized(); } - public function testGetSerializedSerializesSubtablesOfSummaryRows() + public function testGetSerializedSerializesSubtablesOfSummaryRows(): void { $table = new DataTable(); - $table->addRowFromArray(array(Row::COLUMNS => array('label' => 'dimval1', 'visits' => 245))); + $table->addRowFromArray([Row::COLUMNS => ['label' => 'dimval1', 'visits' => 245]]); $summaryRow = new Row([Row::COLUMNS => ['label' => 'others', 'visits' => 500]]); @@ -507,68 +581,68 @@ public function testGetSerializedSerializesSubtablesOfSummaryRows() $results = $table->getSerialized(); - $this->assertCount(2, $results); - $this->assertStringContainsString('dimval1', $results[0]); - $this->assertStringContainsString('subtabledimension', $results[1]); + self::assertCount(2, $results); + self::assertStringContainsString('dimval1', $results[0]); + self::assertStringContainsString('subtabledimension', $results[1]); $tableUnserialized = DataTable::fromSerializedArray($results[0]); - $this->assertEquals(1, $tableUnserialized->getSummaryRow()->getIdSubDataTable()); + self::assertEquals(1, $tableUnserialized->getSummaryRow()->getIdSubDataTable()); $expectedResults = [ 'a:2:{i:0;a:3:{i:0;a:2:{s:5:"label";s:7:"dimval1";s:6:"visits";i:245;}i:1;a:0:{}i:3;N;}i:-1;a:3:{i:0;a:2:{s:5:"label";s:6:"others";s:6:"visits";i:500;}i:1;a:0:{}i:3;i:1;}}', 'a:1:{i:0;a:3:{i:0;a:2:{s:5:"label";s:17:"subtabledimension";s:6:"visits";i:100;}i:1;a:0:{}i:3;N;}}', ]; - $this->assertEquals($expectedResults, $results); + self::assertEquals($expectedResults, $results); } /** * Test queing filters */ - public function testFilterQueueSortString() + public function testFilterQueueSortString(): void { $idcol = Row::COLUMNS; $table = new DataTable(); - $rows = array( - array($idcol => array('label' => 'google')), //0 - array($idcol => array('label' => 'tsk')), //1 - array($idcol => array('label' => 'Q*(%&*("$&%*(&"$*")"))')), //2 - ); + $rows = [ + [$idcol => ['label' => 'google']], //0 + [$idcol => ['label' => 'tsk']], //1 + [$idcol => ['label' => 'Q*(%&*("$&%*(&"$*")"))']], //2 + ]; $table->addRowsFromArray($rows); $expectedtable = new DataTable(); - $rows = array( - array($idcol => array('label' => 'google')), //0 - array($idcol => array('label' => 'Q*(%&*("$&%*(&"$*")"))')), //2 - array($idcol => array('label' => 'tsk')), //1 - ); + $rows = [ + [$idcol => ['label' => 'google']], //0 + [$idcol => ['label' => 'Q*(%&*("$&%*(&"$*")"))']], //2 + [$idcol => ['label' => 'tsk']], //1 + ]; $expectedtable->addRowsFromArray($rows); $expectedtableReverse = new DataTable(); $expectedtableReverse->addRowsFromArray(array_reverse($rows)); $tableCopy = clone $table; - $this->assertTrue(DataTable::isEqual($tableCopy, $table)); + self::assertTrue(DataTable::isEqual($tableCopy, $table)); // queue the filter and check the table didnt change - $table->queueFilter("Sort", array('label', 'asc')); - $this->assertTrue(DataTable::isEqual($tableCopy, $table)); + $table->queueFilter("Sort", ['label', 'asc']); + self::assertTrue(DataTable::isEqual($tableCopy, $table)); // apply filter and check the table is sorted $table->applyQueuedFilters(); - $this->assertTrue(DataTable::isEqual($expectedtable, $table)); + self::assertTrue(DataTable::isEqual($expectedtable, $table)); // apply one more filter check it hasnt changed - $table->queueFilter("Sort", array('label', 'desc')); - $this->assertTrue(DataTable::isEqual($expectedtable, $table)); + $table->queueFilter("Sort", ['label', 'desc']); + self::assertTrue(DataTable::isEqual($expectedtable, $table)); // now apply the second sort and check it is correctly sorted $table->applyQueuedFilters(); - $this->assertTrue(DataTable::isEqual($expectedtableReverse, $table)); + self::assertTrue(DataTable::isEqual($expectedtableReverse, $table)); // do one more time to make sure it doesnt change $table->applyQueuedFilters(); - $this->assertTrue(DataTable::isEqual($expectedtableReverse, $table)); + self::assertTrue(DataTable::isEqual($expectedtableReverse, $table)); } /** @@ -578,13 +652,13 @@ public function testFilterQueueSortString() * * Then we serialize everything, and we check that the unserialize give the same object back */ - public function testGeneral() + public function testGeneral(): void { /* * create some fake tables to make sure that the serialized array of the first TABLE * does not take in consideration those tables */ - $useless1 = $this->createDataTable(array(array(13,))); + $useless1 = $this->createDataTable([[13,]]); /* * end fake tables */ @@ -601,55 +675,70 @@ public function testGeneral() * does not take in consideration those tables * -> we check that the DataTable_Manager is not impacting DataTable */ - $useless1->addRowFromArray(array(Row::COLUMNS => array(8487,),)); - $useless3 = $this->createDataTable(array(array(8487))); + $useless1->addRowFromArray([Row::COLUMNS => [8487,],]); + $useless3 = $this->createDataTable([[8487]]); /* * end fake tables */ - $row = array(Row::COLUMNS => array(0 => 1554, 1 => 42, 2 => 657, 3 => 155744,), - Row::METADATA => array('logo' => 'test.png')); + $row = [ + Row::COLUMNS => [0 => 1554, 1 => 42, 2 => 657, 3 => 155744,], + Row::METADATA => ['logo' => 'test.png'], + ]; $row = new Row($row); $table->addRow($row); - $table->addRowFromArray(array(Row::COLUMNS => array(0 => 1554, 1 => 42,), - Row::METADATA => array('url' => 'piwik.org'))); + $table->addRowFromArray([ + Row::COLUMNS => [0 => 1554, 1 => 42,], + Row::METADATA => ['url' => 'piwik.org'], + ]); - $table->addRowFromArray(array(Row::COLUMNS => array(0 => 787877888787,), - Row::METADATA => array('url' => 'OUPLA ADDED'), - Row::DATATABLE_ASSOCIATED => $subtable)); + $table->addRowFromArray([ + Row::COLUMNS => [0 => 787877888787,], + Row::METADATA => ['url' => 'OUPLA ADDED'], + Row::DATATABLE_ASSOCIATED => $subtable, + ]); /* * SUB TABLE */ - $row = array(Row::COLUMNS => array(0 => 1554,), - Row::METADATA => array('searchengine' => 'google'), - ); + $row = [ + Row::COLUMNS => [0 => 1554,], + Row::METADATA => ['searchengine' => 'google'], + ]; $subtable->addRowFromArray($row); - $row = array(Row::COLUMNS => array(0 => 84894,), - Row::METADATA => array('searchengine' => 'yahoo'), - ); + $row = [ + Row::COLUMNS => [0 => 84894,], + Row::METADATA => ['searchengine' => 'yahoo'], + ]; $subtable->addRowFromArray($row); - $row = array(Row::COLUMNS => array(0 => 4898978989,), - Row::METADATA => array('searchengine' => 'ask'), - ); + $row = [ + Row::COLUMNS => [0 => 4898978989,], + Row::METADATA => ['searchengine' => 'ask'], + ]; $subtable->addRowFromArray($row); /* * SUB SUB TABLE */ $subsubtable = new DataTable(); - $subsubtable->addRowFromArray(array(Row::COLUMNS => array(245), - Row::METADATA => array('yes' => 'subsubmetadata1'),)); + $subsubtable->addRowFromArray([ + Row::COLUMNS => [245], + Row::METADATA => ['yes' => 'subsubmetadata1'], + ]); - $subsubtable->addRowFromArray(array(Row::COLUMNS => array(13,), - Row::METADATA => array('yes' => 'subsubmetadata2'),)); + $subsubtable->addRowFromArray([ + Row::COLUMNS => [13,], + Row::METADATA => ['yes' => 'subsubmetadata2'], + ]); - $row = array(Row::COLUMNS => array(0 => 666666666666666,), - Row::METADATA => array('url' => 'NEW ROW ADDED'), - Row::DATATABLE_ASSOCIATED => $subsubtable); + $row = [ + Row::COLUMNS => [0 => 666666666666666,], + Row::METADATA => ['url' => 'NEW ROW ADDED'], + Row::DATATABLE_ASSOCIATED => $subsubtable, + ]; $subtable->addRowFromArray($row); @@ -657,7 +746,7 @@ public function testGeneral() $serialized = $table->getSerialized(); - $this->assertEquals(array_keys($serialized), array(2, 1, 0)); // subtableIds are now consecutive + self::assertEquals(array_keys($serialized), [2, 1, 0]); // subtableIds are now consecutive // In the next test we compare an unserialized datatable with its original instance. // The unserialized datatable rows will have positive DATATABLE_ASSOCIATED ids. @@ -665,8 +754,8 @@ public function testGeneral() // In this case, this is NOT true: we know that the sub-datatable is loaded in memory. // HOWEVER, because of datatable id conflicts happening in the datatable manager, it is not yet // possible to know, after unserializing a datatable, if its sub-datatables are loaded in memory. - $expectedTableRows = array(); - $i = 0; + $expectedTableRows = []; + $i = 0; foreach ($table->getRows() as $currentRow) { $expectedTableRow = clone $currentRow; @@ -681,19 +770,19 @@ public function testGeneral() $tableAfter = new DataTable(); $tableAfter->addRowsFromSerializedArray($serialized[0]); - $this->assertEquals($expectedTableRows, $tableAfter->getRows()); + self::assertEquals($expectedTableRows, $tableAfter->getRows()); $subsubtableAfter = new DataTable(); $subsubtableAfter->addRowsFromSerializedArray($serialized[$consecutiveSubtableId = 2]); - $this->assertEquals($subsubtable->getRows(), $subsubtableAfter->getRows()); - $this->assertEquals($subsubtable->getRows(), DataTable::fromSerializedArray($serialized[$consecutiveSubtableId = 2])->getRows()); - $this->assertTrue($subsubtable->getRowsCount() > 0); + self::assertEquals($subsubtable->getRows(), $subsubtableAfter->getRows()); + self::assertEquals($subsubtable->getRows(), DataTable::fromSerializedArray($serialized[$consecutiveSubtableId = 2])->getRows()); + self::assertTrue($subsubtable->getRowsCount() > 0); - $this->assertEquals($table, Manager::getInstance()->getTable($idtable)); - $this->assertEquals($subsubtable, Manager::getInstance()->getTable($idsubsubtable)); + self::assertEquals($table, Manager::getInstance()->getTable($idtable)); + self::assertEquals($subsubtable, Manager::getInstance()->getTable($idsubsubtable)); } - public function testGetSerializedShouldCreateConsecutiveSubtableIds() + public function testGetSerializedShouldCreateConsecutiveSubtableIds(): void { $numRowsInRoot = 10; $numRowsInSubtables = 5; @@ -713,7 +802,7 @@ public function testGetSerializedShouldCreateConsecutiveSubtableIds() // we want to make sure the tables have high ids but we will ignore them and just give them Ids starting from 0 $recentId = Manager::getInstance()->getMostRecentTableId(); - $this->assertGreaterThanOrEqual(5000, $recentId); + self::assertGreaterThanOrEqual(5000, $recentId); $tables = $rootTable->getSerialized($numRowsInRoot, $numRowsInSubtables); @@ -721,20 +810,20 @@ public function testGetSerializedShouldCreateConsecutiveSubtableIds() $sumSubTables = ($numRowsInRoot - 1) + (($numRowsInRoot - 1) * ($numRowsInSubtables - 1)); $subtableIds = array_keys($tables); sort($subtableIds); - $this->assertEquals(range(0, $sumSubTables), $subtableIds); + self::assertEquals(range(0, $sumSubTables), $subtableIds); // make sure the rows subtableId were updated as well. foreach ($tables as $index => $serializedRows) { $rows = unserialize($serializedRows); - $this->assertTrue(is_array($rows)); + self::assertTrue(is_array($rows)); if (0 === $index) { // root table, make sure correct amount of rows are in subtables - $this->assertCount($numRowsInRoot, $rows); + self::assertCount($numRowsInRoot, $rows); } foreach ($rows as $row) { - $this->assertTrue(is_array($row)); + self::assertTrue(is_array($row)); $subtableId = $row[Row::DATATABLE_ASSOCIATED]; @@ -742,25 +831,25 @@ public function testGetSerializedShouldCreateConsecutiveSubtableIds() $row[Row::COLUMNS]['label'] === DataTable::LABEL_SUMMARY_ROW || $row[Row::COLUMNS]['label'] === DataTable::LABEL_ARCHIVED_METADATA_ROW ) { - $this->assertNull($subtableId); + self::assertNull($subtableId); } else { - $this->assertLessThanOrEqual($sumSubTables, $subtableId); // make sure row was actually updated - $this->assertGreaterThanOrEqual(0, $subtableId); + self::assertLessThanOrEqual($sumSubTables, $subtableId); // make sure row was actually updated + self::assertGreaterThanOrEqual(0, $subtableId); $subrows = unserialize($tables[$subtableId]); // this way we make sure the rows point to the correct subtable. only 2nd level rows have actually // subtables. All 3rd level datatables do not have a row see table creation further above if ($index === 0) { - $this->assertCount($numRowsInSubtables, $subrows); + self::assertCount($numRowsInSubtables, $subrows); } else { - $this->assertCount(0, $subrows); + self::assertCount(0, $subrows); } } } } } - public function testGetSerializedShouldExportOnlyTheSerializedArrayOfAllTableRows() + public function testGetSerializedShouldExportOnlyTheSerializedArrayOfAllTableRows(): void { $rootTable = new DataTable(); $this->addManyRows($rootTable, 2); @@ -774,14 +863,14 @@ public function testGetSerializedShouldExportOnlyTheSerializedArrayOfAllTableRow $tables = $rootTable->getSerialized(); // we also make sure it actually handles the subtableIds correct etc - $this->assertEquals(array( + self::assertEquals([ 0 => 'a:2:{i:0;a:3:{i:0;a:1:{s:5:"label";s:6:"label0";}i:1;a:0:{}i:3;i:1;}i:1;a:3:{i:0;a:1:{s:5:"label";s:6:"label1";}i:1;a:0:{}i:3;i:2;}}', 1 => 'a:2:{i:0;a:3:{i:0;a:1:{s:5:"label";s:6:"label0";}i:1;a:0:{}i:3;N;}i:1;a:3:{i:0;a:1:{s:5:"label";s:6:"label1";}i:1;a:0:{}i:3;N;}}', 2 => 'a:2:{i:0;a:3:{i:0;a:1:{s:5:"label";s:6:"label0";}i:1;a:0:{}i:3;N;}i:1;a:3:{i:0;a:1:{s:5:"label";s:6:"label1";}i:1;a:0:{}i:3;N;}}', - ), $tables); + ], $tables); } - public function testSerializationOfDataTableMetadata() + public function testSerializationOfDataTableMetadata(): void { $table = new DataTable(); $table->addRow(new Row([ @@ -799,13 +888,13 @@ public function testSerializationOfDataTableMetadata() $newTable = DataTable::fromSerializedArray(reset($serialized)); - $this->assertEquals([ + self::assertEquals([ new Row([ Row::COLUMNS => ['label' => 'abc', 'nb_visits' => 5], ]), ], $newTable->getRows()); - $this->assertEquals([ + self::assertEquals([ 'str' => 'str value', 'int' => 5, 'float' => 3.65, @@ -813,10 +902,10 @@ public function testSerializationOfDataTableMetadata() ], $newTable->getAllTableMetadata()); } - private function addManyRows(DataTable $table, $numRows) + private function addManyRows(DataTable $table, $numRows): void { for ($i = 0; $i < $numRows; $i++) { - $table->addRowFromArray(array(Row::COLUMNS => array('label' => 'label' . $i))); + $table->addRowFromArray([Row::COLUMNS => ['label' => 'label' . $i]]); } } @@ -831,30 +920,30 @@ private function addManyRows(DataTable $table, $numRows) /** * add an empty datatable to a normal datatable */ - public function testAddSimpleNoRowTable2() + public function testAddSimpleNoRowTable2(): void { $table = $this->getDataTable1ForTest(); $tableEmpty = new DataTable(); $tableAfter = clone $table; $tableAfter->addDataTable($tableEmpty); - $this->assertTrue(DataTable::isEqual($table, $tableAfter)); + self::assertTrue(DataTable::isEqual($table, $tableAfter)); } /** * add a normal datatable to an empty datatable */ - public function testAddSimpleNoRowTable1() + public function testAddSimpleNoRowTable1(): void { $table = $this->getDataTable1ForTest(); $tableEmpty = new DataTable(); $tableEmpty->addDataTable($table); - $this->assertTrue(DataTable::isEqual($tableEmpty, $table)); + self::assertTrue(DataTable::isEqual($tableEmpty, $table)); } /** * add to the datatable another datatable// they don't have any row in common */ - public function testAddSimpleNoCommonRow() + public function testAddSimpleNoCommonRow(): void { $table1 = $this->getDataTable1ForTest(); $table2 = $this->getDataTable2ForTest(); @@ -865,120 +954,120 @@ public function testAddSimpleNoCommonRow() $tableExpected = new DataTable(); $tableExpected->addRowsFromArray($rowsExpected); - $this->assertTrue(DataTable::isEqual($table1, $tableExpected)); + self::assertTrue(DataTable::isEqual($table1, $tableExpected)); } /** * add 2 datatable with some common rows */ - public function testAddSimpleSomeCommonRow() + public function testAddSimpleSomeCommonRow(): void { $idcol = Row::COLUMNS; - $rows = array( - array($idcol => array('label' => 'google', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 2)), - array($idcol => array('label' => '123', 'visits' => 2)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7)), - ); + $rows = [ + [$idcol => ['label' => 'google', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 2]], + [$idcol => ['label' => '123', 'visits' => 2]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7]], + ]; $table = new DataTable(); $table->addRowsFromArray($rows); - $rows2 = array( - array($idcol => array('label' => 'test', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 111)), - array($idcol => array('label' => ' google ', 'visits' => 5)), - array($idcol => array('label' => '123', 'visits' => 2)), - ); + $rows2 = [ + [$idcol => ['label' => 'test', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 111]], + [$idcol => ['label' => ' google ', 'visits' => 5]], + [$idcol => ['label' => '123', 'visits' => 2]], + ]; $table2 = new DataTable(); $table2->addRowsFromArray($rows2); $table->addDataTable($table2); - $rowsExpected = array( - array($idcol => array('label' => 'google', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 113)), - array($idcol => array('label' => '123', 'visits' => 4)), - array($idcol => array('label' => 'test', 'visits' => 1)), - array($idcol => array('label' => ' google ', 'visits' => 5)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7)), - ); + $rowsExpected = [ + [$idcol => ['label' => 'google', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 113]], + [$idcol => ['label' => '123', 'visits' => 4]], + [$idcol => ['label' => 'test', 'visits' => 1]], + [$idcol => ['label' => ' google ', 'visits' => 5]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7]], + ]; $tableExpected = new DataTable(); $tableExpected->addRowsFromArray($rowsExpected); - $this->assertTrue(DataTable::isEqual($table, $tableExpected)); + self::assertTrue(DataTable::isEqual($table, $tableExpected)); } /** * add 2 datatable with only common rows */ - public function testAddSimpleAllCommonRow() + public function testAddSimpleAllCommonRow(): void { $idcol = Row::COLUMNS; - $rows = array( - array($idcol => array('label' => 'google', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 2)), - array($idcol => array('label' => '123', 'visits' => 2)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7)), - ); + $rows = [ + [$idcol => ['label' => 'google', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 2]], + [$idcol => ['label' => '123', 'visits' => 2]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 7]], + ]; $table = new DataTable(); $table->addRowsFromArray($rows); - $rows2 = array( - array($idcol => array('label' => 'google', 'visits' => -1)), - array($idcol => array('label' => 'ask', 'visits' => 0)), - array($idcol => array('label' => '123', 'visits' => 1.5)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 8)), - ); + $rows2 = [ + [$idcol => ['label' => 'google', 'visits' => -1]], + [$idcol => ['label' => 'ask', 'visits' => 0]], + [$idcol => ['label' => '123', 'visits' => 1.5]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 8]], + ]; $table2 = new DataTable(); $table2->addRowsFromArray($rows2); $table->addDataTable($table2); - $rowsExpected = array( - array($idcol => array('label' => 'google', 'visits' => 0)), - array($idcol => array('label' => 'ask', 'visits' => 2)), - array($idcol => array('label' => '123', 'visits' => 3.5)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 15)), - ); + $rowsExpected = [ + [$idcol => ['label' => 'google', 'visits' => 0]], + [$idcol => ['label' => 'ask', 'visits' => 2]], + [$idcol => ['label' => '123', 'visits' => 3.5]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 15]], + ]; $tableExpected = new DataTable(); $tableExpected->addRowsFromArray($rowsExpected); - $this->assertTrue(DataTable::isEqual($table, $tableExpected)); + self::assertTrue(DataTable::isEqual($table, $tableExpected)); } /** * test add 2 different tables to the same table */ - public function testAddDataTable2times() + public function testAddDataTable2times(): void { $idcol = Row::COLUMNS; - $rows = array( - array($idcol => array('label' => 'google', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 0)), - array($idcol => array('label' => '123', 'visits' => 2)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 1)), - ); + $rows = [ + [$idcol => ['label' => 'google', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 0]], + [$idcol => ['label' => '123', 'visits' => 2]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 1]], + ]; $table = new DataTable(); $table->addRowsFromArray($rows); - $rows2 = array( - array($idcol => array('label' => 'google2', 'visits' => -1)), - array($idcol => array('label' => 'ask', 'visits' => 100)), - array($idcol => array('label' => '123456', 'visits' => 1.5)), - ); + $rows2 = [ + [$idcol => ['label' => 'google2', 'visits' => -1]], + [$idcol => ['label' => 'ask', 'visits' => 100]], + [$idcol => ['label' => '123456', 'visits' => 1.5]], + ]; $table2 = new DataTable(); $table2->addRowsFromArray($rows2); - $rows3 = array( - array($idcol => array('label' => 'google2', 'visits' => -1)), - array($idcol => array('label' => 'ask', 'visits' => -10)), - array($idcol => array('label' => '123ab', 'visits' => 1.5)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 3)), - ); + $rows3 = [ + [$idcol => ['label' => 'google2', 'visits' => -1]], + [$idcol => ['label' => 'ask', 'visits' => -10]], + [$idcol => ['label' => '123ab', 'visits' => 1.5]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 3]], + ]; $table3 = new DataTable(); $table3->addRowsFromArray($rows3); @@ -986,22 +1075,22 @@ public function testAddDataTable2times() $table->addDataTable($table2); $table->addDataTable($table3); - $rowsExpected = array( - array($idcol => array('label' => 'google', 'visits' => 1)), - array($idcol => array('label' => 'ask', 'visits' => 90)), - array($idcol => array('label' => '123', 'visits' => 2)), - array($idcol => array('label' => 'google2', 'visits' => -2)), - array($idcol => array('label' => '123456', 'visits' => 1.5)), - array($idcol => array('label' => '123ab', 'visits' => 1.5)), - DataTable::ID_SUMMARY_ROW => array($idcol => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 4)), - ); + $rowsExpected = [ + [$idcol => ['label' => 'google', 'visits' => 1]], + [$idcol => ['label' => 'ask', 'visits' => 90]], + [$idcol => ['label' => '123', 'visits' => 2]], + [$idcol => ['label' => 'google2', 'visits' => -2]], + [$idcol => ['label' => '123456', 'visits' => 1.5]], + [$idcol => ['label' => '123ab', 'visits' => 1.5]], + DataTable::ID_SUMMARY_ROW => [$idcol => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 4]], + ]; $tableExpected = new DataTable(); $tableExpected->addRowsFromArray($rowsExpected); - $this->assertTrue(DataTable::isEqual($table, $tableExpected)); + self::assertTrue(DataTable::isEqual($table, $tableExpected)); } - public function testAddDataTableWhenThereIsNoSummaryRowInOneTableAndASummaryRowInTheOtherTable() + public function testAddDataTableWhenThereIsNoSummaryRowInOneTableAndASummaryRowInTheOtherTable(): void { $table1 = new DataTable(); $table1->addRowsFromSimpleArray([ @@ -1039,10 +1128,10 @@ public function testAddDataTableWhenThereIsNoSummaryRowInOneTableAndASummaryRowI return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testAddDataTableWhenThereIsASummaryRowAndRowWithNegativeOneLabel() + public function testAddDataTableWhenThereIsASummaryRowAndRowWithNegativeOneLabel(): void { $table1 = new DataTable(); $table1->addRowsFromSimpleArray([ @@ -1081,12 +1170,12 @@ public function testAddDataTableWhenThereIsASummaryRowAndRowWithNegativeOneLabel return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testUnrelatedDataTableNotDestructed() + public function testUnrelatedDataTableNotDestructed(): void { - $mockedDataTable = $this->createPartialMock('\Piwik\DataTable', array('__destruct')); + $mockedDataTable = $this->createPartialMock('\Piwik\DataTable', ['__destruct']); $mockedDataTable->expects($this->never())->method('__destruct'); $rowBeingDestructed = new Row(); @@ -1103,32 +1192,32 @@ public function testUnrelatedDataTableNotDestructed() /** * @group Core */ - public function testDisableFilterDoesActuallyDisableAFilter() + public function testDisableFilterDoesActuallyDisableAFilter(): void { - $dataTable = DataTable::makeFromSimpleArray(array_fill(0, 100, array())); - $this->assertSame(100, $dataTable->getRowsCount()); + $dataTable = DataTable::makeFromSimpleArray(array_fill(0, 100, [])); + self::assertSame(100, $dataTable->getRowsCount()); $dataTable2 = clone $dataTable; // verify here the filter is applied - $dataTable->filter('Limit', array(10, 10)); - $this->assertSame(10, $dataTable->getRowsCount()); + $dataTable->filter('Limit', [10, 10]); + self::assertSame(10, $dataTable->getRowsCount()); // verify here the filter is not applied as it is disabled $dataTable2->disableFilter('Limit'); - $dataTable2->filter('Limit', array(10, 10)); - $this->assertSame(100, $dataTable2->getRowsCount()); + $dataTable2->filter('Limit', [10, 10]); + self::assertSame(100, $dataTable2->getRowsCount()); // passing a whole className is expected to work. This way we also make sure not all filters are disabled // and it only blocks the given one - $dataTable2->filter('Piwik\DataTable\Filter\Limit', array(10, 10)); - $this->assertSame(10, $dataTable2->getRowsCount()); + $dataTable2->filter('Piwik\DataTable\Filter\Limit', [10, 10]); + self::assertSame(10, $dataTable2->getRowsCount()); } /** * @group Core */ - public function testSubDataTableIsDestructed() + public function testSubDataTableIsDestructed(): void { $mockedDataTable = $this->getMockBuilder('\Piwik\DataTable') ->onlyMethods(['__destruct']) @@ -1141,7 +1230,7 @@ public function testSubDataTableIsDestructed() Common::destroy($rowBeingDestructed); } - public function testSerializeFailsOnSubTableNotFound() + public function testSerializeFailsOnSubTableNotFound(): void { // create a simple table with a subtable $table1 = $this->getDataTable1ForTest(); @@ -1161,11 +1250,11 @@ public function testSerializeFailsOnSubTableNotFound() $serializedStrings = $table2->getSerialized(); // both the main table and the sub table are serialized - $this->assertEquals(sizeof($serializedStrings), 2); + self::assertEquals(sizeof($serializedStrings), 2); // the serialized string references the id subtable $unserialized = unserialize($serializedStrings[0]); - $this->assertSame($idSubtable, $unserialized[0][3], "not found the id sub table in the serialized, not expected"); + self::assertSame($idSubtable, $unserialized[0][3], "not found the id sub table in the serialized, not expected"); // KABOOM, we delete the subtable, reproducing a "random data issue" Manager::getInstance()->deleteTable($table1->getId()); @@ -1176,22 +1265,22 @@ public function testSerializeFailsOnSubTableNotFound() $serializedStrings = $table2->getSerialized(); // - the serialized table does NOT contain the sub table - $this->assertEquals(sizeof($serializedStrings), 1); // main table only is serialized + self::assertEquals(sizeof($serializedStrings), 1); // main table only is serialized $unserialized = unserialize($serializedStrings[0]); // - the serialized string does NOT contain the id subtable (the row was cleaned up as expected) - $this->assertNull($unserialized[0][3], "found the id sub table in the serialized, not expected"); + self::assertNull($unserialized[0][3], "found the id sub table in the serialized, not expected"); } - public function testMergeSubtablesKeepsMetadata() + public function testMergeSubtablesKeepsMetadata(): void { $dataTable = $this->getDataTable1ForTest(); $dataTable->setMetadata('additionalMetadata', 'test'); $dataTable = $dataTable->mergeSubtables(); - $this->assertEquals('test', $dataTable->getMetadata('additionalMetadata')); + self::assertEquals('test', $dataTable->getMetadata('additionalMetadata')); } - public function testSumRowWithLabelAddsNewRowIfTableDoesNotHaveRowWithSameLabel() + public function testSumRowWithLabelAddsNewRowIfTableDoesNotHaveRowWithSameLabel(): void { $dataTable = new DataTable(); $dataTable->addRowsFromSimpleArray([ @@ -1214,10 +1303,10 @@ public function testSumRowWithLabelAddsNewRowIfTableDoesNotHaveRowWithSameLabel( return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testSumRowWithLabelSumsWithExistingRowIfTableDoesHaveRowWithSameLabel() + public function testSumRowWithLabelSumsWithExistingRowIfTableDoesHaveRowWithSameLabel(): void { $dataTable = new DataTable(); $dataTable->addRowsFromSimpleArray([ @@ -1239,10 +1328,10 @@ public function testSumRowWithLabelSumsWithExistingRowIfTableDoesHaveRowWithSame return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testSumRowWithLabelUsesSummaryRowIfLabelIsSpecialRankingQueryLabel() + public function testSumRowWithLabelUsesSummaryRowIfLabelIsSpecialRankingQueryLabel(): void { $dataTable = new DataTable(); $dataTable->addRowsFromSimpleArray([ @@ -1266,10 +1355,10 @@ public function testSumRowWithLabelUsesSummaryRowIfLabelIsSpecialRankingQueryLab return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testSumRowWithLabelDefaultsNullLabelToEmptyString() + public function testSumRowWithLabelDefaultsNullLabelToEmptyString(): void { $dataTable = new DataTable(); $dataTable->addRowsFromSimpleArray([ @@ -1292,10 +1381,10 @@ public function testSumRowWithLabelDefaultsNullLabelToEmptyString() return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - public function testSumRowWithLabelUsesCustomAggregationOpsIfSupplied() + public function testSumRowWithLabelUsesCustomAggregationOpsIfSupplied(): void { $dataTable = new DataTable(); $dataTable->addRowsFromSimpleArray([ @@ -1317,20 +1406,20 @@ public function testSumRowWithLabelUsesCustomAggregationOpsIfSupplied() return $r->getColumns(); }, $actualRows); - $this->assertEquals($expectedRows, $actualRows); + self::assertEquals($expectedRows, $actualRows); } - private function createDataTable($rows) + private function createDataTable(array $rows): DataTable { $useless1 = new DataTable(); foreach ($rows as $row) { - $useless1->addRowFromArray(array(Row::COLUMNS => $row)); + $useless1->addRowFromArray([Row::COLUMNS => $row]); } return $useless1; } - protected function getDataTable1ForTest() + protected function getDataTable1ForTest(): DataTable { $rows = $this->getRowsDataTable1ForTest(); $table = new DataTable(); @@ -1338,7 +1427,7 @@ protected function getDataTable1ForTest() return $table; } - protected function getDataTable2ForTest() + protected function getDataTable2ForTest(): DataTable { $rows = $this->getRowsDataTable2ForTest(); $table = new DataTable(); @@ -1346,25 +1435,25 @@ protected function getDataTable2ForTest() return $table; } - protected function getRowsDataTable1ForTest() + protected function getRowsDataTable1ForTest(): array { - $rows = array( - array(Row::COLUMNS => array('label' => 'google', 'visits' => 1)), - array(Row::COLUMNS => array('label' => 'ask', 'visits' => 2)), - array(Row::COLUMNS => array('label' => '123', 'visits' => 2)), - DataTable::ID_SUMMARY_ROW => array(Row::COLUMNS => array('label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 4)), + $rows = [ + [Row::COLUMNS => ['label' => 'google', 'visits' => 1]], + [Row::COLUMNS => ['label' => 'ask', 'visits' => 2]], + [Row::COLUMNS => ['label' => '123', 'visits' => 2]], + DataTable::ID_SUMMARY_ROW => [Row::COLUMNS => ['label' => DataTable::LABEL_SUMMARY_ROW, 'visits' => 4]], - ); + ]; return $rows; } - protected function getRowsDataTable2ForTest() + protected function getRowsDataTable2ForTest(): array { - $rows = array( - array(Row::COLUMNS => array('label' => 'test', 'visits' => 1)), - array(Row::COLUMNS => array('label' => ' google ', 'visits' => 3)), - array(Row::COLUMNS => array('label' => '123a', 'visits' => 2)), - ); + $rows = [ + [Row::COLUMNS => ['label' => 'test', 'visits' => 1]], + [Row::COLUMNS => ['label' => ' google ', 'visits' => 3]], + [Row::COLUMNS => ['label' => '123a', 'visits' => 2]], + ]; return $rows; } } diff --git a/tests/UI/expected-screenshots/QuickAccess_search_1.png b/tests/UI/expected-screenshots/QuickAccess_search_1.png index 0291b784027..b65d98ca683 100644 --- a/tests/UI/expected-screenshots/QuickAccess_search_1.png +++ b/tests/UI/expected-screenshots/QuickAccess_search_1.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77b991f7a35b16ded1329a70e3fa23cd628fd645e09f7b5917b518a920fc5d40 -size 97591 +oid sha256:a1b70614dc61c501dca879465c88ed700f7ebbbd066a2ed6076fb77c639d226b +size 98822 diff --git a/tests/UI/expected-screenshots/QuickAccess_search_2.png b/tests/UI/expected-screenshots/QuickAccess_search_2.png index b28006afd68..8e416b72cfe 100644 --- a/tests/UI/expected-screenshots/QuickAccess_search_2.png +++ b/tests/UI/expected-screenshots/QuickAccess_search_2.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ce4aa38db7ceb808b715ced83babee0730f8ede4bd763dbd6210c4760860b2b -size 19242 +oid sha256:d2a4ee618afe704c913c3b63c3ab93a3f65156b21e8bd5d0a227b476702fcd00 +size 20533 diff --git a/tests/UI/expected-screenshots/QuickAccess_search_sites.png b/tests/UI/expected-screenshots/QuickAccess_search_sites.png index 28a12316c2b..922aa3920f6 100644 --- a/tests/UI/expected-screenshots/QuickAccess_search_sites.png +++ b/tests/UI/expected-screenshots/QuickAccess_search_sites.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5476a5327898026334999ca2a4a6bdcb8def7a48a062d7accc6c7e7c52686c19 -size 47079 +oid sha256:f726b4f6d615058da976930a1e576b0214823975148f8e3ffcc7050f37795829 +size 48420 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png b/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png index d33d6a30d4b..6ad07e791a2 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_admin_diagnostics_configfile.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b6e714e509b219b1189a31afe03570bc3890975985847cb8d218250e7bad7ffa -size 5210036 +oid sha256:c8ea9c2888b1cf62ff2b4b8bfb4c4e9e05bad47e439837d8dac71a78d8dc6ea6 +size 5226150 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png b/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png index eb393f285e4..1fc8bfa91ae 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_api_listing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:163989f3c5e6b7138d9e2a31800fefcc39d0228d90ff65214036af79b55c9580 -size 5021683 +oid sha256:0faa9cb18b2fed0cf1afe3bdcd4d4dcd43cfa1b7d49e0083c678c32be4c0bd55 +size 5078390 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_email_reports_editor.png b/tests/UI/expected-screenshots/UIIntegrationTest_email_reports_editor.png index d1669354ed8..3db2408ec3f 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_email_reports_editor.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_email_reports_editor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b7ee1bbb3bc59a08120b8abca95aba848b741ea5b4185c089d02dd8d692b5be -size 493103 +oid sha256:7d4e7952d8d0002966c1ed63d76f2e56e66c704a013c94a260dc7518e15a9105 +size 500028 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_glossary.png b/tests/UI/expected-screenshots/UIIntegrationTest_glossary.png index 1f724b42a64..b71281a77e6 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_glossary.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_glossary.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:783af000422a2b0d762b554f6a6bd3520a6955e8c9cbf63051cb3392357a62cd -size 566005 +oid sha256:2efa17af4e3d97e2bb73a2b88fb541931a726771790d76953cc8daa651da9faf +size 719071 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_glossary_widgetized.png b/tests/UI/expected-screenshots/UIIntegrationTest_glossary_widgetized.png index 785ba8bc884..5e0896294d3 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_glossary_widgetized.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_glossary_widgetized.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:983c568dce39c5d7e7e37e7dd089e02fe1286ad7632c00e31538e9586d4ad575 -size 717823 +oid sha256:f2c125f47cfff6181c74013fd3c07f853ab3d6b8be526bfb89565898276656c8 +size 913051 diff --git a/tests/UI/expected-screenshots/UIIntegrationTest_widgets_listing.png b/tests/UI/expected-screenshots/UIIntegrationTest_widgets_listing.png index 0231081caea..ed3f1bf17f5 100644 --- a/tests/UI/expected-screenshots/UIIntegrationTest_widgets_listing.png +++ b/tests/UI/expected-screenshots/UIIntegrationTest_widgets_listing.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76da3574ee97ab67c8b20b13ccfd560f1798478d9205ca6d1bc3bafc468e62c5 -size 183843 +oid sha256:9c28cec08321fb2015fc9573304be7114c0b974cad1d5103a3ad934f1ba44c1a +size 186512