File size: 6,339 Bytes
78185c4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | <?php
/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/
declare(strict_types=1);
namespace Piwik\Plugins\BotTracking\RecordBuilders;
use InvalidArgumentException;
use Piwik\ArchiveProcessor;
use Piwik\Common;
use Piwik\Config\GeneralConfig;
use Piwik\DataTable;
use Piwik\Db;
use Piwik\Plugins\BotTracking\BotDetector;
use Piwik\Plugins\BotTracking\Dao\BotRequestsDao;
use Piwik\Plugins\BotTracking\Metrics;
use Piwik\RankingQuery;
/**
* Shared AI-chatbot page/document URL aggregation, used by the Content Requests
* ({@see AIChatbotContentReports}) and Favoured Pages ({@see AIChatbotFavouredPages}) builders.
*
* Keeping the per-URL request query here gives the "what counts as an AI chatbot request" predicate a
* single source of truth. Each consumer passes only the metric columns it needs, so no caller computes
* columns it discards.
*/
trait AIChatbotPageMetricsTrait
{
/**
* The available per-URL metric columns, mapped to the SQL expression that produces them from the
* AI-chatbot request rows (the `bot` alias in {@see queryPageOrDocumentUrls}). All are summable.
*
* @return array<string, string> column name => SQL select expression
*/
protected function pageUrlMetricExpressions(): array
{
return [
Metrics::COLUMN_REQUESTS => 'COUNT(*)',
Metrics::COLUMN_SUM_SERVER_TIME => 'SUM(bot.response_time_ms)',
Metrics::COLUMN_NB_SERVER_TIME => 'SUM(CASE WHEN bot.response_time_ms IS NOT NULL THEN 1 ELSE 0 END)',
Metrics::COLUMN_SUM_RESPONSE_SIZE => 'SUM(bot.response_size_bytes)',
Metrics::COLUMN_NB_RESPONSE_SIZE => 'SUM(CASE WHEN bot.response_size_bytes IS NOT NULL THEN 1 ELSE 0 END)',
Metrics::COLUMN_PAGE_NOT_FOUND_404_REQUESTS => 'SUM(bot.http_status_code IN (404, 410))',
Metrics::COLUMN_SERVER_ERROR_5XX_REQUESTS => 'SUM(bot.http_status_code BETWEEN 500 AND 599)',
];
}
/**
* Queries page or document URLs requested by AI chatbots, selecting only the requested metric
* columns (Content the full set, Favoured Pages just the request count).
*
* @param string[] $columnNames metric columns to select, from {@see pageUrlMetricExpressions} keys
*/
protected function queryPageOrDocumentUrls(ArchiveProcessor $archiveProcessor, int $actionType, int $rankingQueryLimit, array $columnNames): DataTable
{
$logAggregator = $archiveProcessor->getLogAggregator();
$where = $logAggregator->getWhereStatement('bot', 'server_time');
$botTable = BotRequestsDao::getPrefixedTableName();
$actionTable = Common::prefixTable('log_action');
$expressions = $this->pageUrlMetricExpressions();
$selects = ['log_action.name AS url'];
$columns = [];
foreach ($columnNames as $columnName) {
if (!isset($expressions[$columnName])) {
throw new InvalidArgumentException('Unknown AI chatbot page metric column: ' . $columnName);
}
$selects[$columnName] = $expressions[$columnName] . ' AS ' . $columnName;
$columns[$columnName] = 'sum';
}
// Truncate by the request count when present (every consumer selects it); otherwise the first
// requested column, so the RankingQuery keeps the highest-volume rows.
$orderColumn = isset($columns[Metrics::COLUMN_REQUESTS]) ? Metrics::COLUMN_REQUESTS : (string) array_key_first($columns);
$innerSql = sprintf(
"SELECT %s
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 %s DESC",
implode(",\n ", $selects),
$botTable,
$actionTable,
$actionType,
$where,
$orderColumn
);
return $this->executeUrlQuery($archiveProcessor, $innerSql, $columns, $rankingQueryLimit);
}
/**
* Wraps an already-built inner SQL with a RankingQuery and populates a DataTable; the "Others"
* summary row is routed by sumRowWithLabel.
*
* @param string $innerSql Already-interpolated SQL (bot_type bind placeholder kept as `?`).
* @param array<string, string> $columns column name → RankingQuery aggregation op; must match the
* SELECT aliases.
*/
protected function executeUrlQuery(ArchiveProcessor $archiveProcessor, string $innerSql, array $columns, int $rankingQueryLimit): DataTable
{
$logAggregator = $archiveProcessor->getLogAggregator();
$rankingQuery = new RankingQuery($rankingQueryLimit);
$rankingQuery->addLabelColumn('url');
foreach ($columns as $column => $op) {
$rankingQuery->addColumn($column, $op);
}
$wrappedSql = $rankingQuery->generateRankingQuery($innerSql);
$bind = array_merge([BotDetector::BOT_TYPE_AI_CHATBOT], $logAggregator->getGeneralQueryBindParams());
$stmt = Db::query($wrappedSql, $bind);
$table = new DataTable();
while ($row = $stmt->fetch()) {
/** @var array<string, int|string|null> $row */
$label = (string) $row['url'];
$metrics = [];
foreach ($columns as $column => $op) {
$raw = $row[$column] ?? 0;
$metrics[$column] = is_numeric($raw) ? (int) $raw : 0;
}
$table->sumRowWithLabel($label, $metrics);
}
return $table;
}
protected function getRankingQueryLimit(int $maxRowsInTable): int
{
$configLimit = GeneralConfig::getIntegerConfigValue('archiving_ranking_query_row_limit', 0);
// As we are querying flat data, use `maxRowsInTable` as ranking query limit as it would be pointless to query more
return max($configLimit, $maxRowsInTable);
}
}
|