Files
Investidor-V2/app/Services/PublicSearchMetrics.php
T

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;
}
}