* - PHP constant ABJ404_QUERY_BUDGET_LOG (string path to a writable directory)
*
* When enabled, every queryAndGetResults() call records:
* - resolved SQL source identifier (filename, /* abj404:src=ID */ marker,
* or backtrace-derived Class::method — see DataAccess::extractSqlFilename)
* - elapsed wall-clock ms
* - per-query timeout hint actually used (s)
* - timestamp + request URI
*
* On shutdown, if any single query exceeded its per-query budget OR the request's
* cumulative DB time exceeded the request budget (default 25s — below the
* Cloudflare ~100s and nginx ~60s reverse-proxy cutoffs, with margin for
* non-DB request work), one JSONL violation entry is appended to
* `
/slow-query-budget-violations.log`.
*
* The log shape is intentionally append-only JSONL so multiple Playwright
* worker processes can write concurrently without coordination, and so
* failing E2E runs can attach the file as an artifact for triage.
*/
class ABJ_404_Solution_QueryBudgetInstrumentation {
/** Default per-request cumulative budget in milliseconds (admin/AJAX). */
const DEFAULT_REQUEST_BUDGET_MS = 25000;
/** Default per-query budget in milliseconds — same as the cumulative cap. */
const DEFAULT_QUERY_BUDGET_MS = 25000;
/** @var bool|null Lazily resolved enabled flag. */
private static $enabled = null;
/** @var string|null Lazily resolved log directory. */
private static $logDir = null;
/** @var bool */
private static $shutdownRegistered = false;
/**
* Per-request recording buffer.
*
* @var array{queries: list, request_budget_ms: int, started_at: float}|null
*/
private static $state = null;
/**
* Returns true if instrumentation is enabled for this process.
*
* Reads from the ABJ404_QUERY_BUDGET_LOG environment variable or the same
* named PHP constant. The value, if non-empty, is the directory the
* violations log is written to. An explicit "0" / "false" / empty value
* keeps it disabled.
*/
public static function isEnabled(): bool {
if (self::$enabled !== null) {
return self::$enabled;
}
$dir = self::resolveLogDir();
self::$enabled = ($dir !== null && $dir !== '');
return self::$enabled;
}
/**
* Returns the log directory, or null if instrumentation is disabled or
* the directory is unwritable. Result is cached.
*/
public static function logDir(): ?string {
if (self::$logDir !== null) {
return self::$logDir === '' ? null : self::$logDir;
}
$dir = self::resolveLogDir();
if ($dir === null || $dir === '') {
self::$logDir = '';
return null;
}
if (!is_dir($dir)) {
$made = @mkdir($dir, 0755, true);
if (!$made && !is_dir($dir)) {
self::$logDir = '';
return null;
}
}
if (!is_writable($dir)) {
self::$logDir = '';
return null;
}
self::$logDir = $dir;
return self::$logDir;
}
/** @return string|null */
private static function resolveLogDir(): ?string {
$envVal = getenv('ABJ404_QUERY_BUDGET_LOG');
if (is_string($envVal) && $envVal !== '' && $envVal !== '0' && strtolower($envVal) !== 'false') {
return $envVal;
}
if (defined('ABJ404_QUERY_BUDGET_LOG')) {
$constVal = constant('ABJ404_QUERY_BUDGET_LOG');
if (is_string($constVal) && $constVal !== '' && $constVal !== '0' && strtolower($constVal) !== 'false') {
return $constVal;
}
}
return null;
}
/**
* Path to the violations log inside the configured log directory.
*/
public static function violationLogPath(): ?string {
$dir = self::logDir();
if ($dir === null) {
return null;
}
return rtrim($dir, "/\\") . DIRECTORY_SEPARATOR . 'slow-query-budget-violations.log';
}
/**
* Per-request cumulative budget in ms. Override by defining
* ABJ404_REQUEST_BUDGET_MS as a positive integer.
*/
public static function requestBudgetMs(): int {
if (defined('ABJ404_REQUEST_BUDGET_MS')) {
$v = constant('ABJ404_REQUEST_BUDGET_MS');
if (is_int($v) && $v > 0) {
return $v;
}
if (is_string($v) && ctype_digit($v) && (int)$v > 0) {
return (int)$v;
}
}
return self::DEFAULT_REQUEST_BUDGET_MS;
}
/**
* Records a single queryAndGetResults() invocation.
*
* @param string $sqlInfo Resolved source identifier (NOT raw SQL — keeps log PII-free)
* @param float $elapsedMs Wall-clock duration in milliseconds
* @param int $timeoutSeconds The per-query timeout hint actually applied
* @return void
*/
public static function recordQuery(string $sqlInfo, float $elapsedMs, int $timeoutSeconds): void {
if (!self::isEnabled()) {
return;
}
if (self::$state === null) {
self::initState();
}
// Ensure the shutdown flush is wired even when the early-return paths
// in queryAndGetResults() short-circuit before WordPress finishes
// booting. register_shutdown_function is idempotent for our purposes
// because flushOnShutdown() guards against double-emission.
self::ensureShutdownRegistered();
// Empty input would erase the source attribution that drives triage,
// so substitute a stable sentinel. DataAccess::extractSqlFilename
// never produces empty strings — this guards against external callers.
$sqlInfo = $sqlInfo === '' ? 'unknown-source' : $sqlInfo;
if (strlen($sqlInfo) > 200) {
$sqlInfo = substr($sqlInfo, 0, 200);
}
/** @var array{queries: list, request_budget_ms: int, started_at: float} $state */
$state = self::$state;
$state['queries'][] = array(
'sql' => $sqlInfo,
'elapsed_ms' => max(0.0, $elapsedMs),
'timeout_s' => max(0, $timeoutSeconds),
'ts' => microtime(true),
);
self::$state = $state;
}
/** @return void */
private static function initState(): void {
self::$state = array(
'queries' => array(),
'request_budget_ms' => self::requestBudgetMs(),
'started_at' => microtime(true),
);
}
/** @return void */
private static function ensureShutdownRegistered(): void {
if (self::$shutdownRegistered) {
return;
}
self::$shutdownRegistered = true;
register_shutdown_function(array(__CLASS__, 'flushOnShutdown'));
}
/**
* Flush on shutdown: if the request violated the budget, append one JSONL
* entry to the violations log.
*
* @return void
*/
public static function flushOnShutdown(): void {
if (!self::isEnabled() || self::$state === null) {
return;
}
$entry = self::buildViolationEntry();
// Reset state so re-flush (e.g. test calling flushOnShutdown() twice)
// does not double-emit.
$state = self::$state;
self::$state = null;
if ($entry === null) {
return;
}
$path = self::violationLogPath();
if ($path === null) {
return;
}
$line = json_encode($entry, JSON_UNESCAPED_SLASHES);
if ($line === false) {
return;
}
// file_put_contents with FILE_APPEND | LOCK_EX is concurrency-safe
// across Playwright worker processes.
@file_put_contents($path, $line . "\n", FILE_APPEND | LOCK_EX);
}
/**
* Build a violation entry for the current request, or null if no budget
* was exceeded.
*
* @return array|null
*/
private static function buildViolationEntry(): ?array {
if (self::$state === null) {
return null;
}
$budgetMs = self::$state['request_budget_ms'];
$totalMs = 0.0;
$perQueryViolations = array();
foreach (self::$state['queries'] as $q) {
$totalMs += $q['elapsed_ms'];
// Per-query budget: timeout_s converted to ms, capped at the
// request budget (since a single query above the request budget
// is by definition a violation).
$perQueryBudgetMs = min(self::DEFAULT_QUERY_BUDGET_MS,
$q['timeout_s'] > 0 ? $q['timeout_s'] * 1000 : self::DEFAULT_QUERY_BUDGET_MS);
if ($q['elapsed_ms'] > $perQueryBudgetMs) {
$perQueryViolations[] = array(
'sql' => $q['sql'],
'elapsed_ms' => round($q['elapsed_ms'], 2),
'budget_ms' => $perQueryBudgetMs,
'reason' => 'per-query',
);
}
}
$cumulativeViolation = $totalMs > $budgetMs;
if (empty($perQueryViolations) && !$cumulativeViolation) {
return null;
}
return array(
'ts' => gmdate('Y-m-d\TH:i:s\Z'),
'uri' => self::currentRequestUri(),
'request_total_ms' => round($totalMs, 2),
'request_budget_ms' => $budgetMs,
'query_count' => count(self::$state['queries']),
'cumulative_violation' => $cumulativeViolation,
'violations' => $perQueryViolations,
);
}
/** @return string */
private static function currentRequestUri(): string {
$uri = isset($_SERVER['REQUEST_URI']) && is_string($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '';
if ($uri === '' && isset($_SERVER['SCRIPT_NAME']) && is_string($_SERVER['SCRIPT_NAME'])) {
$uri = $_SERVER['SCRIPT_NAME'];
}
if ($uri === '' && PHP_SAPI === 'cli') {
$uri = 'cli://' . (isset($_SERVER['argv'][0]) ? basename((string)$_SERVER['argv'][0]) : 'php');
}
return $uri === '' ? '(unknown)' : substr($uri, 0, 500);
}
/**
* Reset all internal state. Test-only. Production code never calls this.
*
* @return void
*/
public static function resetForTests(): void {
self::$enabled = null;
self::$logDir = null;
self::$state = null;
self::$shutdownRegistered = false;
}
}
/**
* Top-level recording entry point — called from queryAndGetResults(). The
* indirection through a free function (rather than a direct class method
* call) mirrors the existing abj404_benchmark_record_db_query() hook so that
* environments without the instrumentation file loaded incur zero cost
* (the function_exists() guard at the call site short-circuits).
*
* @param string $sqlInfo
* @param float $elapsedMs
* @param int $timeoutSeconds
* @return void
*/
if (!function_exists('abj404_query_budget_record')) {
function abj404_query_budget_record(string $sqlInfo, float $elapsedMs, int $timeoutSeconds): void {
ABJ_404_Solution_QueryBudgetInstrumentation::recordQuery(
$sqlInfo,
$elapsedMs,
$timeoutSeconds
);
}
}