Newer
Older
smart-home-server / server / Fury / Kernel / Logging.php
<?php

namespace Fury\Kernel;

/**
 * Class for free logging
 */

class Logging extends \Fury\Libs\Singleton{
	/**
	 * Storage for session logs
	 *
	 * @var arrray
	 */
	protected $storage;

	/**
	 * Unique ID of SESSION
	 *
	 * @var string
	 */
	protected $session_id;

	/**
	 * Path to folder with logs
	 *
	 * @var string
	 */
	public $logs_folder;

	public function __construct(){
		if(!FCONF['logs_enable'])
				return false;

		$this -> storage = [];
		$this -> session_id = uniqid();
		$this -> logs_folder = FCONF['logs_folder'];
	}

	/**
	 * Set new log item (legacy session-based API)
	 *
	 * @method set
	 *
	 * @param  string $place String in format "Classname@methname" or "funcname"
	 * @param  string $title Title of log.
	 * @param  string $message Any text message
	 */
	public function set($place, $title, $message){
		if(!FCONF['logs_enable'])
				return false;

		if(strpos($place, '@') === false){
			$class = '';
			$meth = $place;
		}else{
			list($class, $meth) = explode('@', $place);
		}

		$this -> storage[] = [
			'class' => $class,
			'meth' => $meth,
			'title' => $title,
			'message' => $message,
			'timestamp' => microtime(true)
		];

		return true;
	}

	// ============================================================
	// Structured JSON Lines logging (new)
	// ============================================================

	const LEVEL_TRACE = 'TRACE';
	const LEVEL_DEBUG = 'DEBUG';
	const LEVEL_INFO  = 'INFO';
	const LEVEL_WARN  = 'WARN';
	const LEVEL_ERROR = 'ERROR';
	const LEVEL_FATAL = 'FATAL';

	protected static $write_counter = 0;

	/**
	 * Get current request/session correlation id
	 */
	public function get_request_id(): string {
		return $this -> session_id;
	}

	public function trace(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_TRACE, $source, $message, $context);
	}

	public function debug(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_DEBUG, $source, $message, $context);
	}

	public function info(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_INFO, $source, $message, $context);
	}

	public function warn(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_WARN, $source, $message, $context);
	}

	public function error(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_ERROR, $source, $message, $context);
	}

	public function fatal(string $source, string $message, array $context = []){
		return $this -> log(self::LEVEL_FATAL, $source, $message, $context);
	}

	/**
	 * Write a structured log entry immediately to JSON Lines file
	 */
	public function log(string $level, string $source, string $message, array $context = []){
		if(!FCONF['logs_enable'])
			return false;

		if($this -> is_level_below_min($level))
			return false;

		if(!$this -> is_source_allowed($source))
			return false;

		$entry = [
			'ts'         => date('c'),
			'level'      => $level,
			'source'     => $source,
			'request_id' => $this -> session_id,
			'message'    => $message,
			'context'    => $this -> redact($context),
		];

		$line = json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";

		$path = $this -> get_jsonl_path();
		$this -> rotate_if_needed($path);
		$this -> cleanup_if_needed();

		$fp = fopen($path, 'a');
		if($fp === false)
			return false;

		if(flock($fp, LOCK_EX)){
			fwrite($fp, $line);
			fflush($fp);
			flock($fp, LOCK_UN);
		}
		fclose($fp);
		chmod($path, 0644);

		return true;
	}

	// ============================================================
	// Config helpers
	// ============================================================

	private function conf(string $key, $default = null){
		return FCONF[$key] ?? $default;
	}

	private function is_level_below_min(string $level): bool {
		$map = [
			self::LEVEL_TRACE => 0,
			self::LEVEL_DEBUG => 1,
			self::LEVEL_INFO  => 2,
			self::LEVEL_WARN  => 3,
			self::LEVEL_ERROR => 4,
			self::LEVEL_FATAL => 5,
		];
		$min = strtoupper($this -> conf('logs_min_level', self::LEVEL_INFO));
		return ($map[$level] ?? 2) < ($map[$min] ?? 2);
	}

	private function is_source_allowed(string $source): bool {
		$allowed = $this -> conf('logs_sources', []);
		if(empty($allowed))
			return true;
		return in_array($source, $allowed, true);
	}

	// ============================================================
	// File path, rotation, cleanup
	// ============================================================

	private function get_jsonl_path(): string {
		$folder = $this -> logs_folder;
		if(!is_dir($folder)){
			mkdir($folder, 0750, true);
		}
		return $folder . '/' . date('Y-m-d') . '.jsonl';
	}

	private function rotate_if_needed(string $path): void {
		if(!file_exists($path))
			return;

		$max_mb = (int) $this -> conf('logs_max_file_size_mb', 50);
		$max_bytes = $max_mb * 1024 * 1024;
		if(filesize($path) < $max_bytes)
			return;

		$rotated = $path . '.' . date('Y-m-d_H-i-s');
		@rename($path, $rotated);
	}

	private function cleanup_if_needed(): void {
		self::$write_counter++;
		if(self::$write_counter % 100 !== 0)
			return;

		$max_days = (int) $this -> conf('logs_max_days', 30);
		$cutoff = time() - ($max_days * 86400);
		$folder = $this -> logs_folder;

		foreach(glob($folder . '/*.jsonl*') as $file){
			if(filemtime($file) < $cutoff){
				@unlink($file);
			}
		}
	}

	// ============================================================
	// Redaction
	// ============================================================

	private function redact(array $context): array {
		$pattern = '/password|token|authorization|secret|key/i';
		return $this -> redact_recursive($context, $pattern);
	}

	private function redact_recursive($data, string $pattern) {
		if(is_array($data)){
			$result = [];
			foreach($data as $k => $v){
				if(preg_match($pattern, (string)$k)){
					$result[$k] = '***';
				}else{
					$result[$k] = $this -> redact_recursive($v, $pattern);
				}
			}
			return $result;
		}
		return $data;
	}

	/**
	 * Dumping session logs to json file (legacy format)
	 *
	 * @method dump
	 *
	 * @return boolean Result of writing to log file
	 */
	public function dump(){
		$log_filename = date('d.m.Y') . '.log.json';
		$path_to_log_file = $this -> logs_folder . '/' . $log_filename;
		$session = [
			'session_id' => $this -> session_id,
			'timestamp' => microtime(true),
			'logs' => $this -> storage
		];

		if(!is_dir($this -> logs_folder)){
			mkdir($this -> logs_folder, 0750, true);
		}

		$fp = fopen($path_to_log_file, 'c+');
		if($fp === false) {
			return false;
		}

		if(!flock($fp, LOCK_EX)) {
			fclose($fp);
			return false;
		}

		$raw = stream_get_contents($fp);
		$logs = $raw ? json_decode($raw, true) : [];
		if(!is_array($logs)) {
			$logs = [];
		}
		$logs[] = $session;

		ftruncate($fp, 0);
		rewind($fp);
		$result = fwrite($fp, json_encode($logs, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT));
		fflush($fp);
		flock($fp, LOCK_UN);
		fclose($fp);

		chmod($path_to_log_file, 0640);

		return $result !== false;
	}
}