Bicycle/System/Classes/Log.php
Egor Isaev 4e41390544 dev
2026-06-26 12:52:41 +03:00

211 lines
7.1 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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).
* Публичный — переиспользуется читалками логов (FileLogReader).
*
* @return string
*/
public 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;
}
}