diff --git a/composer.json b/composer.json index b09ff7d..289cf11 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "lcobucci/jwt": "^4.2", "aws/aws-sdk-php": "^3.289.0", "phpstan/phpstan": "^1.8", - "siketyan/yarn-lock": "^1.0" + "siketyan/yarn-lock": "^1.0", + "sentry/sentry": "^4.8" }, "scripts": { "phpstan": "vendor/bin/phpstan analyse -l 8 src/ router/ --memory-limit 1G", diff --git a/composer.lock b/composer.lock index 1ab8be6..eeaaa70 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f1d70b0db4d156870b527b13114d6ff1", + "content-hash": "b9c3ac0de0df644080e27bc263c795e6", "packages": [ { "name": "aws/aws-crt-php", @@ -1617,6 +1617,65 @@ ], "time": "2023-12-03T20:05:35+00:00" }, + { + "name": "jean85/pretty-package-versions", + "version": "2.0.6", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/f9fdd29ad8e6d024f52678b570e5593759b550b4", + "reference": "f9fdd29ad8e6d024f52678b570e5593759b550b4", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.0.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^7.5|^8.5|^9.4", + "vimeo/psalm": "^4.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.0.6" + }, + "time": "2024-03-08T09:58:59+00:00" + }, { "name": "latte/latte", "version": "v3.0.14", @@ -3058,6 +3117,95 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "sentry/sentry", + "version": "4.8.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "3cf5778ff425a23f2d22ed41b423691d36f47163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3cf5778ff425a23f2d22ed41b423691d36f47163", + "reference": "3cf5778ff425a23f2d22ed41b423691d36f47163", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^1.0|^2.0", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "monolog/monolog": "^1.6|^2.0|^3.0", + "phpbench/phpbench": "^1.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.14|^9.4", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "vimeo/psalm": "^4.17" + }, + "suggest": { + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.8.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2024-06-05T13:18:43+00:00" + }, { "name": "siketyan/yarn-lock", "version": "v1.1.0", @@ -4080,6 +4228,73 @@ ], "time": "2024-04-03T06:09:15+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/47aa818121ed3950acd2b58d1d37d08a94f9bf55", + "reference": "47aa818121ed3950acd2b58d1d37d08a94f9bf55", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-05-31T14:57:53+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.29.0", diff --git a/src/Bootstrap.php b/src/Bootstrap.php index b22b188..8820fab 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -3,9 +3,9 @@ namespace Megio; +use Megio\Debugger\JsonLogstashLogger; use Nette\DI\Compiler; use Nette\Neon\Neon; -use Megio\Debugger\Logger; use Megio\Extension\Extension; use Megio\Helper\Path; use Nette\Bridges\DITracy\ContainerPanel; @@ -13,25 +13,19 @@ use Nette\DI\ContainerLoader; use Symfony\Component\Dotenv\Dotenv; use Tracy\Debugger; +use Tracy\ILogger; class Bootstrap { + protected bool $invokedLogger = false; + public function projectRootPath(string $rootPath): Bootstrap { /** @var string $realPath */ $realPath = realpath($rootPath); Path::setProjectPath($realPath); - return $this; - } - - /** - * @param string $configPath - * @param float $startedAt - * @return Container - */ - public function configure(string $configPath, float $startedAt): Container - { - // Load .env + + // Load environment variables $_ENV = array_merge(getenv(), $_ENV); $envPath = Path::wwwDir() . '/../.env'; @@ -40,18 +34,39 @@ public function configure(string $configPath, float $startedAt): Container $dotenv->loadEnv($envPath); } - date_default_timezone_set($_ENV['APP_TIME_ZONE']); - + return $this; + } + + public function logger(ILogger $logger): Bootstrap + { // Setup debugger - Debugger::setLogger(new Logger(Path::logDir())); - Debugger::enable($_ENV['APP_ENV_MODE'] === 'develop' ? Debugger::Development : Debugger::Production, Path::logDir()); + Debugger::enable($_ENV['APP_ENVIRONMENT'] === 'develop' ? Debugger::Development : Debugger::Production, Path::logDir()); Debugger::$strictMode = E_ALL; + Debugger::setLogger($logger); if (array_key_exists('TRACY_EDITOR', $_ENV) && array_key_exists('TRACY_EDITOR_MAPPING', $_ENV)) { Debugger::$editor = $_ENV['TRACY_EDITOR']; Debugger::$editorMapping = ['/var/www/html' => $_ENV['TRACY_EDITOR_MAPPING']]; } + $this->invokedLogger = true; + + return $this; + } + + /** + * @param string $configPath + * @param float $startedAt + * @return Container + */ + public function configure(string $configPath, float $startedAt): Container + { + if ($this->invokedLogger === false) { + $this->logger(new JsonLogstashLogger()); + } + + date_default_timezone_set($_ENV['APP_TIME_ZONE']); + // Create DI container $container = $this->createContainer($configPath); $container->parameters['startedAt'] = $startedAt; @@ -74,7 +89,7 @@ public function configure(string $configPath, float $startedAt): Container */ protected function createContainer(string $configPath): Container { - $loader = new ContainerLoader(Path::tempDir() . '/di', $_ENV['APP_ENV_MODE'] === 'develop'); + $loader = new ContainerLoader(Path::tempDir() . '/di', $_ENV['APP_ENVIRONMENT'] === 'develop'); /** @var Container $class */ $class = $loader->load(function (Compiler $compiler) use ($configPath) { diff --git a/src/Debugger/BaseLogger.php b/src/Debugger/BaseLogger.php new file mode 100644 index 0000000..cfcd69d --- /dev/null +++ b/src/Debugger/BaseLogger.php @@ -0,0 +1,114 @@ +getMessage() . $throwable->getFile() . $throwable->getLine() . $throwable->getTraceAsString()); + } + + protected function createBlueScreen(\Throwable $message, string $fileName): void + { + (new BlueScreen)->renderToFile($message, Path::logDir() . '/' . $fileName); + } + + protected function createBlueScreenFileName(string $hash): string + { + return 'blue-screen-' . $hash . '.html'; + } + + protected function uploadBlueScreenToS3OnEnvEnabled(string $fileName): void + { + $filePathName = Path::logDir() . "/{$fileName}"; + + if (array_key_exists('LOG_S3_BLUESCREEN', $_ENV) + && mb_strtolower($_ENV['LOG_S3_BLUESCREEN']) === 'true' + && file_exists($filePathName) + ) { + $storage = new S3Storage(); + if (count($storage->list(".tracy/{$fileName}")) === 0) { + $file = new UploadedFile($filePathName, $fileName, 'text/html'); + $storage->upload($file, ".tracy/", false); + } + } + } + + /** + * @return array + */ + protected function createTracyPayload(\Throwable $throwable): array + { + $hash = $this->createErrorHash($throwable); + $fileName = $this->createBlueScreenFileName($hash); + $link = $this->tracyUri ? ($this->tracyUri . $hash) : ($_ENV['APP_URL'] . '/app/logs/tracy/' . $hash); + + return [ + 'hash' => $hash, + 'filename' => $fileName, + 'link' => $link + ]; + } + + /** + * @param array $message + * @param string $level + * @param \DateTime $now + * @param array $context + * @return array + */ + protected function createBasicPayload(string $message, string $level, \DateTime $now, array $context): array + { + return [ + '@timestamp' => $now->format('Y-m-d\TH:i:s.uP'), + '@version' => 1, + 'host' => array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : null, + 'message' => $message, + 'type' => $_ENV['APP_ENVIRONMENT'], + 'channel' => 'default', + 'level' => $level, + 'context' => $context, + ]; + } + + /** + * @param array $context + */ + protected function sendMailOnEnvEnabled(array $context, string $level, \DateTime $now): void + { + if (array_key_exists('LOG_MAIL', $_ENV) && $_ENV['LOG_MAIL'] !== '' && !in_array($level, [self::DEBUG, self::INFO])) { + $utcTime = $now->format('Y-m-d\TH:i:s.uP'); + $snooze = strtotime('1 day') - time(); + + $body = array_map(function ($key, $value) { + $value = json_encode($value, JSON_PRETTY_PRINT); + return "{$key}: {$value}"; + }, array_keys($context), $context); + + + if (@filemtime(Path::logDir() . '/email-sent') + $snooze < time() // @ file may not exist + && @file_put_contents(Path::logDir() . '/email-sent', $utcTime) // @ file may not be writable + ) { + $message = new Message(); + $message->setFrom($_ENV['SMTP_SENDER'], $_ENV['APP_NAME']); + $message->addTo($_ENV['LOG_MAIL']); + $message->setSubject("[LOG] {$_ENV['APP_NAME']} | {$utcTime}"); + $message->setBody("Application {$_ENV['APP_NAME']} just crashed.\r\n\r\n" . implode("\r\n", $body)); + + (new SmtpMailer())->send($message); + } + } + } +} \ No newline at end of file diff --git a/src/Debugger/JsonLogstashLogger.php b/src/Debugger/JsonLogstashLogger.php new file mode 100644 index 0000000..ea39845 --- /dev/null +++ b/src/Debugger/JsonLogstashLogger.php @@ -0,0 +1,93 @@ +format('Y-m-d'); + $payload = $this->formatPayload($message, $level, $now); + + /** @var string $json */ + $json = json_encode($payload); + $filePathName = Path::logDir() . "/{$date}--logstash.json.log"; + + if (!@file_put_contents($filePathName, $json . PHP_EOL, FILE_APPEND | LOCK_EX)) { + throw new \RuntimeException("Unable to write to log file '{$filePathName}'. Is directory writable?"); + } + + if ($message instanceof \Throwable) { + $hash = $this->createErrorHash($message); + $fileName = $this->createBlueScreenFileName($hash); + + $this->createBlueScreen($message, $fileName); + $this->uploadBlueScreenToS3OnEnvEnabled($fileName); + $this->sendMailOnEnvEnabled($payload, $level, $now); + } + } + + /** + * @param mixed $message + * @param string $level + * @param \DateTime $now + * @return array + */ + protected function formatPayload(mixed $message, string $level, \DateTime $now): array + { + $messageString = 'Unknown message'; + if ($message instanceof \Throwable) { + $messageString = $message->getMessage(); + } else if (is_array($message) && array_key_exists('message', $message)) { + $messageString = $message['message']; + unset($message['message']); + } else if (is_string($message)) { + $messageString = $message; + } + + $ctx = $message instanceof \Throwable + ? ['tracy' => $this->createTracyPayload($message)] + : (is_string($message) ? [] : $message); + + $payload = $this->createBasicPayload($messageString, $level, $now, $ctx); + + // Mask sensitive data in context + if (is_array($payload['context'])) { + $payload['context'] = $this->maskSensitiveContextData($payload['context']); + } + + return $payload; + } + + /** + * @param array $data + * @return array + */ + protected function maskSensitiveContextData(array $data): array + { + // Mask context.request.headers.Authorization + if (array_key_exists('request', $data) && is_array($data['request'])) { + if (array_key_exists('headers', $data['request']) && is_array($data['request']['headers'])) { + if (array_key_exists('Authorization', $data['request']['headers'])) { + $data['request']['headers']['Authorization'] = '****Masked****'; + } + } + } + + return $data; + } +} \ No newline at end of file diff --git a/src/Debugger/Logger.php b/src/Debugger/Logger.php deleted file mode 100644 index 48b78b5..0000000 --- a/src/Debugger/Logger.php +++ /dev/null @@ -1,129 +0,0 @@ -storage = new S3Storage(); - } - - /** - * @param mixed $message - * @param string $level - * @return string - * @throws \Exception - */ - public function log($message, string $level = self::INFO): string - { - $dateTime = new \DateTime(); - $date = $dateTime->format('Y-m-d'); - $time = $dateTime->format('H-i-s'); - - $context = [ - 'level' => $level, - 'timestamp' => $dateTime->format('Y-m-d H:i:s'), - 'message' => $message instanceof \Throwable ? null : $message, - ]; - - $bsFilePrefix = $this->makeBlueScreen($message, $level, $date, $time); - - if ($bsFilePrefix) { - $context = array_merge($context, [ - 'message' => $message->getMessage(), - 'file' => $message->getFile(), - 'line' => $message->getLine(), - 'code' => $message->getCode(), - 'tracy_bs_prefix' => $bsFilePrefix - ]); - } - - /** @var string $json */ - $json = json_encode($context); - $logFilePath = $this->directory . "/{$date}--app-json.log"; - - if (!@file_put_contents($logFilePath, $json . PHP_EOL, FILE_APPEND | LOCK_EX)) { - throw new \RuntimeException("Unable to write to log file '{$logFilePath}'. Is directory writable?"); - } - - if (array_key_exists('LOG_ADAPTER', $_ENV) && mb_strtolower($_ENV['LOG_ADAPTER']) === 's3') { - $this->storage->put("tracy-logs/{$date}--app-json.log", $json); - } - - if (array_key_exists('LOG_MAIL', $_ENV) && $_ENV['LOG_MAIL'] !== '' && !in_array($level, [self::DEBUG, self::INFO])) { - $this->sendMail($context); - } - - return "$bsFilePrefix--$date--$time.html"; - } - - private function makeBlueScreen(mixed $message, string $level, string $date, string $time): ?string - { - if ($message instanceof \Throwable) { - - $exceptionFilePath = $this->getExceptionFile($message, $level); - list($type, , , $hash) = explode('--', (new \SplFileInfo($exceptionFilePath))->getFilename()); - - $hash = str_replace('.html', '', $hash); - $bsFilePrefix = "{$hash}--{$type}"; - $bsFileName = "{$bsFilePrefix}--{$date}--{$time}.html"; - $bsFilePath = $this->directory . '/' . $bsFileName; - - if (iterator_count(Finder::findFiles("{$hash}--{$type}*.html")->in($this->directory . '/')->getIterator()) === 0) { - $bs = new BlueScreen(); - $bs->renderToFile($message, $bsFilePath); - } - - if (array_key_exists('LOG_ADAPTER', $_ENV) && mb_strtolower($_ENV['LOG_ADAPTER']) === 's3' && file_exists($bsFilePath)) { - $files = $this->storage->list("tracy-logs/blue-screens/{$bsFilePrefix}"); - if (count($files) === 0) { - $file = new UploadedFile($bsFilePath, $bsFileName, 'text/html'); - $this->storage->upload($file, "tracy-logs/blue-screens/", false); - } - } - - return $bsFilePrefix; - } - - return null; - } - - /** - * @param array $context - * @return void - */ - private function sendMail(array $context): void - { - $snooze = strtotime('1 day') - time(); - $body = array_map(function ($key, $value) { - $value = is_array($value) ? json_encode($value) : $value; - return "{$key}: {$value}"; - }, array_keys($context), $context); - - if (@filemtime($this->directory . '/email-sent') + $snooze < time() // @ file may not exist - && @file_put_contents($this->directory . '/email-sent', $context['timestamp']) // @ file may not be writable - ) { - $message = new Message(); - $message->setFrom($_ENV['SMTP_SENDER'], $_ENV['APP_NAME']); - $message->addTo($_ENV['LOG_MAIL']); - $message->setSubject("[LOG] {$_ENV['APP_NAME']} | {$context['timestamp']}"); - $message->setBody("Application {$_ENV['APP_NAME']} just crashed.\r\n\r\n" . implode("\r\n", $body)); - - (new SmtpMailer())->send($message); - } - } -} \ No newline at end of file diff --git a/src/Debugger/SentryLogger.php b/src/Debugger/SentryLogger.php new file mode 100644 index 0000000..ecadbb7 --- /dev/null +++ b/src/Debugger/SentryLogger.php @@ -0,0 +1,125 @@ + $options + */ + public function __construct( + protected array $options = [], + protected ?string $tracyUri = null + ) + { + \Sentry\init(array_merge([ + 'dsn' => $_ENV['LOG_SENTRY_DSN'], + 'environment' => $_ENV['APP_ENVIRONMENT'], + 'attach_stacktrace' => true, + 'traces_sample_rate' => 1.0, + 'profiles_sample_rate' => 1.0, + 'default_integrations' => false, + 'send_default_pii' => true, + 'logger' => new DebugStdOutLogger(), + 'integrations' => [ + new ErrorListenerIntegration(), + //new ExceptionListenerIntegration(), + new FatalErrorListenerIntegration(), + new RequestIntegration(), + new EnvironmentIntegration(), + new FrameContextifierIntegration(), + new TransactionIntegration(), + new ModulesIntegration(), + ], + ], $options)); + } + + public function log(mixed $message, string $level = self::INFO): ?EventId + { + $now = new \DateTime(); + + if ($message instanceof \Throwable) { + $hash = $this->createErrorHash($message); + $fileName = $this->createBlueScreenFileName($hash); + $tracy = $this->createTracyPayload($message); + + $this->createBlueScreen($message, $fileName); + $this->uploadBlueScreenToS3OnEnvEnabled($fileName); + $this->addTracyContext($tracy); + + $payload = $this->createBasicPayload($message->getMessage(), $level, $now, ['tracy' => $tracy]); + $this->sendMailOnEnvEnabled($payload, $level, $now); + + return captureException($message); + } + + if (is_array($message)) { + $title = array_key_exists('message', $message) ? $message['message'] : 'Unknown message'; + unset($message['message']); + $this->capturePayload($message); + return $this->captureMessage($title, $level); + } + + return $this->captureMessage(is_string($message) ? $message : Dumper::toText($message), $level); + } + + /** + * @param array $payload + */ + protected function capturePayload(array $payload): void + { + addBreadcrumb('debugger', null, $payload, \Sentry\Breadcrumb::LEVEL_DEBUG); + } + + protected function captureMessage(string $message, string $level = ILogger::ERROR): ?EventId + { + $severity = $this->mapSeverity($level); + return captureMessage($message, $severity); + } + + /** + * @param array $tracy + * @return void + */ + protected function addTracyContext(array $tracy): void + { + configureScope(function (Scope $scope) use ($tracy): void { + $scope->setContext('tracy', $tracy); + }); + } + + protected function mapSeverity(string $level = ILogger::ERROR): Severity + { + $level = mb_strtolower($level); + + $levelMap = [ + self::DEBUG => Severity::debug(), + self::INFO => Severity::info(), + self::WARNING => Severity::warning(), + self::ERROR => Severity::error(), + self::EXCEPTION => Severity::error(), + self::CRITICAL => Severity::fatal(), + ]; + + return array_key_exists($level, $levelMap) ? $levelMap[$level] : Severity::fatal(); + } +} \ No newline at end of file