<?php
/**
* Detailed HTTP request telemetry logger
*
* Store this file OUTSIDE public_html, for example:
*
* /home/CPANELUSER/private/request_logger.php
*
* Logs:
* /home/CPANELUSER/private_logs/request-YYYY-MM-DD.jsonl
*
* Do NOT log:
* - Cookie values
* - Authorization
* - passwords
* - CSRF tokens
* - session IDs
* - API keys
*/
declare(strict_types=1);
$requestStart = microtime(true);
/**
* Safely read $_SERVER value.
*/
function srv(string $key): ?string
{
if (!isset($_SERVER[$key])) {
return null;
}
$value = trim((string) $_SERVER[$key]);
return $value !== '' ? $value : null;
}
/**
* Convert a header name to its normal $_SERVER equivalent.
*
* Example:
* Sec-Fetch-Site -> HTTP_SEC_FETCH_SITE
*/
function headerValue(string $name): ?string
{
$key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
return srv($key);
}
/**
* Return only COOKIE NAMES.
*
* We deliberately do NOT record cookie values.
*/
function cookieNames(): array
{
if (empty($_COOKIE)) {
return [];
}
$names = array_keys($_COOKIE);
sort($names, SORT_STRING);
return $names;
}
/**
* Basic request identifier.
*/
function requestId(): string
{
try {
return bin2hex(random_bytes(12));
} catch (Throwable $e) {
return uniqid('req_', true);
}
}
/**
* Keep line-breaking/control characters out of logs.
*/
function cleanValue(?string $value, int $maxLength = 4096): ?string
{
if ($value === null) {
return null;
}
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $value);
if ($value === null) {
return null;
}
if (strlen($value) > $maxLength) {
$value = substr($value, 0, $maxLength);
}
return $value;
}
$reqId = requestId();
/*
* Capture information that is available when the request arrives.
*/
$requestTelemetry = [
/*
* Identity / time
*/
'timestamp_utc' => gmdate('c'),
'request_id' => $reqId,
/*
* Connection
*/
'remote_addr' => srv('REMOTE_ADDR'),
'remote_port' => srv('REMOTE_PORT'),
/*
* If using Cloudflare these may be extremely useful.
*
* Only TRUST these headers if requests cannot bypass Cloudflare.
*/
'cf' => [
'connecting_ip' => headerValue('CF-Connecting-IP'),
'country' => headerValue('CF-IPCountry'),
'ray' => headerValue('CF-Ray'),
'visitor' => headerValue('CF-Visitor'),
],
/*
* Proxy headers.
*
* Do not automatically regard these as trustworthy.
*/
'proxy' => [
'forwarded' => headerValue('Forwarded'),
'x_forwarded_for' => headerValue('X-Forwarded-For'),
'x_real_ip' => headerValue('X-Real-IP'),
],
/*
* Request
*/
'request' => [
'method' => srv('REQUEST_METHOD'),
'scheme' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'
? 'https'
: 'http',
'host' => cleanValue(headerValue('Host'), 512),
'uri' => cleanValue(srv('REQUEST_URI'), 8192),
'query_string' => cleanValue(srv('QUERY_STRING'), 8192),
'protocol' => srv('SERVER_PROTOCOL'),
'server_port' => srv('SERVER_PORT'),
'content_type' => cleanValue(srv('CONTENT_TYPE')),
'content_length' => srv('CONTENT_LENGTH'),
],
/*
* Traditional browser headers
*/
'headers' => [
'user_agent' => cleanValue(headerValue('User-Agent')),
'accept' => cleanValue(
headerValue('Accept')
),
'accept_language' => cleanValue(
headerValue('Accept-Language')
),
'accept_encoding' => cleanValue(
headerValue('Accept-Encoding')
),
'referer' => cleanValue(
headerValue('Referer'),
8192
),
'origin' => cleanValue(
headerValue('Origin'),
2048
),
'cache_control' => cleanValue(
headerValue('Cache-Control')
),
'pragma' => cleanValue(
headerValue('Pragma')
),
'connection' => cleanValue(
headerValue('Connection')
),
'upgrade_insecure_requests' => cleanValue(
headerValue('Upgrade-Insecure-Requests')
),
'dnt' => cleanValue(
headerValue('DNT')
),
'priority' => cleanValue(
headerValue('Priority')
),
'te' => cleanValue(
headerValue('TE')
),
'upgrade' => cleanValue(
headerValue('Upgrade')
),
],
/*
* Fetch Metadata.
*
* Extremely useful when trying to distinguish a real browser
* navigation from a raw HTTP client merely copying a User-Agent.
*/
'fetch_metadata' => [
'sec_fetch_site' => cleanValue(
headerValue('Sec-Fetch-Site')
),
'sec_fetch_mode' => cleanValue(
headerValue('Sec-Fetch-Mode')
),
'sec_fetch_dest' => cleanValue(
headerValue('Sec-Fetch-Dest')
),
'sec_fetch_user' => cleanValue(
headerValue('Sec-Fetch-User')
),
],
/*
* Chromium Client Hints.
*/
'client_hints' => [
'sec_ch_ua' => cleanValue(
headerValue('Sec-CH-UA')
),
'sec_ch_ua_mobile' => cleanValue(
headerValue('Sec-CH-UA-Mobile')
),
'sec_ch_ua_platform' => cleanValue(
headerValue('Sec-CH-UA-Platform')
),
'sec_ch_ua_platform_version' => cleanValue(
headerValue('Sec-CH-UA-Platform-Version')
),
'sec_ch_ua_arch' => cleanValue(
headerValue('Sec-CH-UA-Arch')
),
'sec_ch_ua_bitness' => cleanValue(
headerValue('Sec-CH-UA-Bitness')
),
'sec_ch_ua_model' => cleanValue(
headerValue('Sec-CH-UA-Model')
),
'sec_ch_ua_full_version_list' => cleanValue(
headerValue('Sec-CH-UA-Full-Version-List')
),
'sec_ch_ua_wow64' => cleanValue(
headerValue('Sec-CH-UA-WoW64')
),
],
/*
* TLS information.
*
* These depend entirely on what your hosting provider exposes
* to PHP. Often these will be NULL on shared hosting.
*/
'tls' => [
'https' => srv('HTTPS'),
'ssl_protocol' => srv('SSL_PROTOCOL'),
'ssl_cipher' => srv('SSL_CIPHER'),
'ssl_session' => srv('SSL_SESSION_ID'),
/*
* Placeholders. PHP ordinarily cannot calculate these itself
* because the TLS handshake occurred before PHP was invoked.
*/
'ja3' => headerValue('X-JA3'),
'ja4' => headerValue('X-JA4'),
],
/*
* Cookie/session continuity.
*
* Names only. NEVER dump $_COOKIE wholesale.
*/
'cookies' => [
'count' => count($_COOKIE),
'names' => cookieNames(),
],
/*
* Some hosting stacks expose these; others don't.
*/
'server' => [
'software' => srv('SERVER_SOFTWARE'),
'name' => srv('SERVER_NAME'),
],
];
/*
* Run after PHP/XenForo finishes so we can add response information.
*/
register_shutdown_function(
function () use ($requestStart, $requestTelemetry): void {
$entry = $requestTelemetry;
$entry['response'] = [
'status' => http_response_code(),
'duration_ms' => round(
(microtime(true) - $requestStart) * 1000,
3
),
'peak_memory_bytes' => memory_get_peak_usage(true),
];
$lastError = error_get_last();
if ($lastError !== null) {
$entry['php_error'] = [
'type' => $lastError['type'] ?? null,
'message' => $lastError['message'] ?? null,
'file' => $lastError['file'] ?? null,
'line' => $lastError['line'] ?? null,
];
}
$date = gmdate('Y-m-d');
/*
* CHANGE THIS.
*/
$logDirectory = '/home/CPANELUSER/private_logs';
if (!is_dir($logDirectory)) {
@mkdir($logDirectory, 0700, true);
}
$logFile = $logDirectory . '/request-' . $date . '.jsonl';
$json = json_encode(
$entry,
JSON_UNESCAPED_SLASHES |
JSON_UNESCAPED_UNICODE |
JSON_INVALID_UTF8_SUBSTITUTE
);
if ($json !== false) {
@file_put_contents(
$logFile,
$json . PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
}
);