210 lines
7.0 KiB
PHP
210 lines
7.0 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description Log.php
|
||
* @copyright (c) 24/06/2026
|
||
*/
|
||
|
||
namespace System\Classes;
|
||
|
||
/**
|
||
* Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню.
|
||
* Используется для аудита запросов (info) и логирования ошибок (error).
|
||
*
|
||
* Log::info('Пользователь :id вошёл', [':id' => 42]);
|
||
* Log::error('Сбой оплаты');
|
||
*/
|
||
class Log
|
||
{
|
||
public const DEBUG = 'debug';
|
||
public const INFO = 'info';
|
||
public const WARNING = 'warning';
|
||
public const ERROR = 'error';
|
||
|
||
/** @var array<string,int> Веса уровней для фильтрации по порогу */
|
||
private const WEIGHTS = [
|
||
self::DEBUG => 100,
|
||
self::INFO => 200,
|
||
self::WARNING => 300,
|
||
self::ERROR => 400,
|
||
];
|
||
|
||
/** @var array<string,string> Канал (файл) для каждого уровня */
|
||
private const CHANNELS = [
|
||
self::DEBUG => 'action', // действия пользователя (аудит)
|
||
self::INFO => 'action',
|
||
self::WARNING => 'error', // ошибки и предупреждения
|
||
self::ERROR => 'error',
|
||
];
|
||
|
||
/** @var string|null Каталог логов; null → Config('log','path') → APPPATH/logs */
|
||
public static ?string $directory = null;
|
||
|
||
/** @var string|null Минимальный уровень записи; null → Config('log','threshold') → debug */
|
||
public static ?string $threshold = null;
|
||
|
||
/** @var array Ключи, значения которых маскируются (***) при логировании query/post */
|
||
public static array $mask_keys = ['password', 'pass', 'csrf_token', 'token'];
|
||
|
||
/**
|
||
* Пишет сообщение в лог, если его уровень не ниже порога.
|
||
*
|
||
* @param string $level Уровень (см. константы)
|
||
* @param string $message Сообщение, может содержать плейсхолдеры для $variables
|
||
* @param array $variables Карта замен [':key' => 'value'] для strtr
|
||
* @return void
|
||
*/
|
||
public static function write(string $level, string $message, array $variables = []): void
|
||
{
|
||
if (self::weight($level) < self::weight(self::threshold())) {
|
||
return;
|
||
}
|
||
|
||
if ($variables) {
|
||
$message = strtr($message, $variables);
|
||
}
|
||
|
||
$dir = self::directory();
|
||
if (!is_dir($dir)) {
|
||
mkdir($dir, 0775, true);
|
||
}
|
||
|
||
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message);
|
||
$file = $dir . '/' . self::channel($level) . '-' . date('Y-m-d') . '.log';
|
||
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
|
||
}
|
||
|
||
/**
|
||
* @param string $message Сообщение
|
||
* @param array $variables Карта замен для strtr
|
||
* @return void
|
||
*/
|
||
public static function debug(string $message, array $variables = []): void
|
||
{
|
||
self::write(self::DEBUG, $message, $variables);
|
||
}
|
||
|
||
/**
|
||
* @param string $message Сообщение
|
||
* @param array $variables Карта замен для strtr
|
||
* @return void
|
||
*/
|
||
public static function info(string $message, array $variables = []): void
|
||
{
|
||
self::write(self::INFO, $message, $variables);
|
||
}
|
||
|
||
/**
|
||
* @param string $message Сообщение
|
||
* @param array $variables Карта замен для strtr
|
||
* @return void
|
||
*/
|
||
public static function warning(string $message, array $variables = []): void
|
||
{
|
||
self::write(self::WARNING, $message, $variables);
|
||
}
|
||
|
||
/**
|
||
* @param string $message Сообщение
|
||
* @param array $variables Карта замен для strtr
|
||
* @return void
|
||
*/
|
||
public static function error(string $message, array $variables = []): void
|
||
{
|
||
self::write(self::ERROR, $message, $variables);
|
||
}
|
||
|
||
/**
|
||
* Собирает строку контекста текущего запроса для аудита/лога ошибок.
|
||
* Чувствительные значения query/post маскируются.
|
||
*
|
||
* @return string '' если текущего запроса нет (CLI/тесты)
|
||
*/
|
||
public static function requestInfo(): string
|
||
{
|
||
$request = Request::$current;
|
||
if ($request === null) {
|
||
return '';
|
||
}
|
||
|
||
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
|
||
$params = json_encode($request->params(), $flags);
|
||
$query = json_encode(self::mask($request->query()), $flags);
|
||
$post = json_encode(self::mask($request->post()), $flags);
|
||
|
||
return sprintf(
|
||
'%s /%s %s::%s params=%s query=%s post=%s',
|
||
$request->method(), $request->uri(), $request->controller(), $request->action(),
|
||
$params, $query, $post
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Минимальный уровень записи (из свойства, конфига или дефолт debug).
|
||
*
|
||
* @return string
|
||
*/
|
||
private static function threshold(): string
|
||
{
|
||
return self::$threshold ?? Config::get('log', 'threshold') ?? self::DEBUG;
|
||
}
|
||
|
||
/**
|
||
* Каталог логов (из свойства, конфига или дефолт APPPATH/logs).
|
||
*
|
||
* @return string
|
||
*/
|
||
private static function directory(): string
|
||
{
|
||
return self::$directory ?? Config::get('log', 'path') ?? APPPATH . '/logs';
|
||
}
|
||
|
||
/**
|
||
* Вес уровня для сравнения с порогом.
|
||
*
|
||
* @param string $level Уровень
|
||
* @return int
|
||
*/
|
||
private static function weight(string $level): int
|
||
{
|
||
return self::WEIGHTS[strtolower($level)] ?? self::WEIGHTS[self::DEBUG];
|
||
}
|
||
|
||
/**
|
||
* Канал (имя файла без даты) для уровня: error/warning → 'error',
|
||
* info/debug → 'action'.
|
||
*
|
||
* @param string $level Уровень
|
||
* @return string
|
||
*/
|
||
private static function channel(string $level): string
|
||
{
|
||
return self::CHANNELS[strtolower($level)] ?? 'action';
|
||
}
|
||
|
||
/**
|
||
* Рекурсивно маскирует значения чувствительных ключей (без учёта регистра).
|
||
*
|
||
* @param array $data Данные
|
||
* @return array
|
||
*/
|
||
private static function mask(array $data): array
|
||
{
|
||
$mask = array_map('strtolower', self::$mask_keys);
|
||
$result = [];
|
||
|
||
foreach ($data as $key => $value) {
|
||
if (in_array(strtolower((string)$key), $mask, true)) {
|
||
$result[$key] = '***';
|
||
} elseif (is_array($value)) {
|
||
$result[$key] = self::mask($value);
|
||
} else {
|
||
$result[$key] = $value;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|