145 lines
9.7 KiB
PHP
145 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Only anonymous daily counters; no query, filter values, visitor or request identifier is stored. */
|
|
final class PublicSearchMetrics
|
|
{
|
|
public const ATTRIBUTE = 'product.search_metric';
|
|
|
|
/** @param array<array-key,mixed> $filters */
|
|
public function candidate(Request $request, string $channel, int $total, array $filters): void
|
|
{
|
|
if (! config('search-metrics.enabled') || ! array_key_exists($channel, config('search-metrics.channels')) || $total < 0) {
|
|
return;
|
|
}
|
|
if (! $request->isMethod('GET') || $request->integer('page', 1) !== 1) {
|
|
return;
|
|
}
|
|
foreach ($filters as $key => $value) {
|
|
if (! is_string($key)) {
|
|
continue;
|
|
}
|
|
if (in_array($key, config('search-metrics.presentation_keys'), true) || ! $request->query->has($key)) {
|
|
continue;
|
|
}
|
|
if ($value === null || $value === '' || $value === [] || $value === false || (is_string($value) && trim($value) === '')) {
|
|
continue;
|
|
}
|
|
$request->attributes->set(self::ATTRIBUTE, ['channel' => $channel, 'empty' => $total === 0]);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
public function record(string $channel, bool $empty): void
|
|
{
|
|
if (! config('search-metrics.enabled') || ! array_key_exists($channel, config('search-metrics.channels'))) {
|
|
return;
|
|
}
|
|
try {
|
|
// A savepoint also protects an existing request transaction if collection fails.
|
|
DB::transaction(function () use ($channel, $empty) {
|
|
$settings = DB::selectOne("SELECT current_setting('lock_timeout') AS old_lock, current_setting('statement_timeout') AS old_statement, (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout') AS lock_ms, (SELECT setting::bigint FROM pg_settings WHERE name='statement_timeout') AS statement_ms");
|
|
$lock = $this->budget('capture_lock_timeout_ms', 100);
|
|
$statement = $this->budget('capture_statement_timeout_ms', 300);
|
|
if ($settings->lock_ms > 0) {
|
|
$lock = min($lock, $settings->lock_ms);
|
|
}
|
|
if ($settings->statement_ms > 0) {
|
|
$statement = min($statement, $settings->statement_ms);
|
|
}
|
|
DB::selectOne("SELECT set_config('lock_timeout',?,true), set_config('statement_timeout',?,true)", [$lock.'ms', $statement.'ms']);
|
|
if (Schema::hasTable('product_search_counts') && Schema::hasTable('product_search_definitions')) {
|
|
$at = CarbonImmutable::now('UTC');
|
|
$definition = $this->definition();
|
|
$hash = hash('sha256', json_encode(Arr::sortRecursive($definition), JSON_THROW_ON_ERROR));
|
|
DB::table('product_search_definitions')->insertOrIgnore(['version' => $definition['version'], 'sha256' => $hash, 'definition' => json_encode($definition, JSON_THROW_ON_ERROR), 'created_at' => $at->format('Y-m-d H:i:s.uP')]);
|
|
if (DB::table('product_search_definitions')->where('version', $definition['version'])->value('sha256') !== $hash) {
|
|
throw new \LogicException('search_definition_conflict');
|
|
}
|
|
DB::statement(<<<'SQL'
|
|
INSERT INTO product_search_counts(day,channel,definition_version,responses,zero_responses,first_recorded_at,last_recorded_at)
|
|
VALUES(?,?,?,1,?,?,?)
|
|
ON CONFLICT(day,channel,definition_version) DO UPDATE SET
|
|
responses=product_search_counts.responses+1,
|
|
zero_responses=product_search_counts.zero_responses+EXCLUDED.zero_responses,
|
|
first_recorded_at=LEAST(product_search_counts.first_recorded_at,EXCLUDED.first_recorded_at),
|
|
last_recorded_at=GREATEST(product_search_counts.last_recorded_at,EXCLUDED.last_recorded_at)
|
|
SQL, [$at->toDateString(), $channel, config('search-metrics.version'), $empty ? 1 : 0, $at->format('Y-m-d H:i:s.uP'), $at->format('Y-m-d H:i:s.uP')]);
|
|
}
|
|
// Successful nested transactions must restore the caller's settings explicitly.
|
|
// On error, rollback to the savepoint restores both settings automatically.
|
|
DB::selectOne("SELECT set_config('lock_timeout',?,true), set_config('statement_timeout',?,true)", [$settings->old_lock, $settings->old_statement]);
|
|
});
|
|
} catch (\Throwable $error) {
|
|
// Never log the exception message/SQL, request, terms, headers or actor.
|
|
try {
|
|
Log::warning('product-search-metrics-unavailable', ['channel' => $channel, 'definition_version' => config('search-metrics.version'), 'exception_class' => $error::class]);
|
|
} catch (\Throwable) {
|
|
// Logging failure must not replace the already-produced search response either.
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Called inside the authorised report snapshot. Daily aggregates have their own time basis.
|
|
* @return array<string,mixed> */
|
|
public function report(string $from, string $to, ?string $service, ?string $requestedVersion): array
|
|
{
|
|
$installed = Schema::hasTable('product_search_counts') && Schema::hasTable('product_search_definitions');
|
|
$registered = $installed ? DB::table('product_search_definitions')->orderBy('version')->get()->keyBy('version') : null;
|
|
$current = $this->definition();
|
|
$version = $requestedVersion === null || $requestedVersion === '' ? $current['version'] : $requestedVersion;
|
|
$available = array_values(array_unique([...($registered?->pluck('version')->all() ?? []), $current['version']]));
|
|
if (! in_array($version, $available, true)) {
|
|
throw ValidationException::withMessages(['search_definition' => 'Escolha uma edição de recolha existente.']);
|
|
}
|
|
$stored = $registered?->get($version);
|
|
$definition = $stored ? json_decode($stored->definition, true, 512, JSON_THROW_ON_ERROR) : $current;
|
|
$rows = $installed && $service === null ? DB::table('product_search_counts')->where('definition_version', $version)->whereBetween('day', [$from, $to])->orderBy('day')->orderBy('channel')->get() : null;
|
|
$channels = [];
|
|
foreach ($definition['channels'] as $code => $label) {
|
|
$selected = $rows?->where('channel', $code);
|
|
$known = $selected !== null && $selected->isNotEmpty();
|
|
$count = $known ? $selected->sum('responses') : null;
|
|
$zeros = $known ? $selected->sum('zero_responses') : null;
|
|
$channels[] = ['id' => $code, 'label' => $label, 'responses' => $count, 'zero_responses' => $zeros, 'zero_rate' => $count > 0 ? round(100 * $zeros / $count, 2) : null,
|
|
'first_recorded_at' => $known ? $selected->min('first_recorded_at') : null, 'last_recorded_at' => $known ? $selected->max('last_recorded_at') : null,
|
|
'definition_versions' => $selected?->pluck('definition_version')->unique()->values()->all() ?? []];
|
|
}
|
|
$known = $rows !== null && $rows->isNotEmpty();
|
|
$responses = $known ? $rows->sum('responses') : null;
|
|
$zeros = $known ? $rows->sum('zero_responses') : null;
|
|
|
|
return ['installed' => $installed, 'enabled_now' => (bool) config('search-metrics.enabled'), 'applicable' => $service === null,
|
|
'from' => $from, 'to' => $to, 'timezone' => 'UTC', 'read_at' => DB::selectOne('select statement_timestamp() AS at')->at,
|
|
'definition_version' => $version, 'available_definitions' => $available, 'definition' => $definition, 'definition_conflict_now' => $stored !== null && $version === $current['version'] && $stored->sha256 !== hash('sha256', json_encode(Arr::sortRecursive($current), JSON_THROW_ON_ERROR)),
|
|
'responses' => $responses, 'zero_responses' => $zeros, 'zero_rate' => $responses > 0 ? round(100 * $zeros / $responses, 2) : null,
|
|
'channels' => $channels, 'exclusions' => $definition['exclusions'], 'completeness' => 'unverified_best_effort',
|
|
'temporal_basis' => 'daily_aggregate_at_report_snapshot',
|
|
'limits' => ['São respostas de primeira página com critérios explícitos, não pessoas ou pesquisas únicas.', 'Sem linhas no período significa ausência de registos de medição, não zero pesquisas.', 'A recolha não bloqueia a pesquisa; falhas de recolha podem deixar respostas por contar. A taxa refere-se apenas às respostas registadas.', 'Contagens por dia UTC são lidas no snapshot do relatório; o dia actual é parcial e não permite um corte por fracção de segundo.', 'Um filtro de serviço do Balcão não se aplica a estes catálogos públicos; nesse caso, os valores ficam sem cálculo.', 'As sugestões relacionadas não alteram o total principal usado para classificar uma resposta sem resultados.']];
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
private function definition(): array
|
|
{
|
|
return ['version' => config('search-metrics.version'), 'channels' => config('search-metrics.channels'), 'presentation_keys' => config('search-metrics.presentation_keys'), 'exclusions' => config('search-metrics.exclusions'),
|
|
'unit' => 'successful_first_page_http_search_response', 'zero' => 'primary_result_total_equals_zero', 'privacy' => 'daily_channel_counts_only_no_terms_filters_identifiers_or_visitors'];
|
|
}
|
|
|
|
private function budget(string $key, int $default): int
|
|
{
|
|
$value = filter_var(config('search-metrics.'.$key), FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 5000]]);
|
|
|
|
return $value === false ? $default : $value;
|
|
}
|
|
}
|