├── src ├── Exceptions │ ├── RuntimeException.php │ ├── LogicalException.php │ ├── Logical │ │ └── InvalidStateException.php │ └── Runtime │ │ └── Logger │ │ └── SlackBadRequestException.php ├── Mailer │ ├── IMailer.php │ ├── FileMailer.php │ └── TracyMailer.php ├── Slack │ ├── Formatter │ │ ├── IFormatter.php │ │ ├── SlackContextField.php │ │ ├── ContextFormatter.php │ │ ├── ColorFormatter.php │ │ ├── ExceptionStackTraceFormatter.php │ │ ├── ExceptionPreviousExceptionsFormatter.php │ │ ├── ExceptionFormatter.php │ │ ├── SlackContextAttachment.php │ │ └── SlackContext.php │ └── SlackLogger.php ├── NullLogger.php ├── ILogger.php ├── UniversalLogger.php ├── Utils │ └── Utils.php ├── FileLogger.php ├── BlueScreenFileLogger.php ├── AbstractLogger.php ├── DI │ ├── SentryLoggingExtension.php │ ├── TracyLoggingExtension.php │ └── SlackLoggingExtension.php ├── SendMailLogger.php └── Sentry │ └── SentryLogger.php ├── .github ├── .kodiak.toml └── workflows │ └── main.yaml ├── Makefile ├── LICENSE └── composer.json /src/Exceptions/RuntimeException.php: -------------------------------------------------------------------------------- 1 | request = $request; 20 | } 21 | 22 | /** 23 | * @return mixed[] 24 | */ 25 | public function getRequest(): array 26 | { 27 | return $this->request; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/Slack/Formatter/SlackContextField.php: -------------------------------------------------------------------------------- 1 | data['title'] = $title; 14 | } 15 | 16 | public function setValue(string $value): void 17 | { 18 | $this->data['value'] = $value; 19 | } 20 | 21 | public function setShort(bool $short = true): void 22 | { 23 | $this->data['short'] = $short; 24 | } 25 | 26 | /** 27 | * @return mixed[] 28 | */ 29 | public function toArray(): array 30 | { 31 | return $this->data; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /src/UniversalLogger.php: -------------------------------------------------------------------------------- 1 | loggers[] = $logger; 16 | } 17 | 18 | 19 | /** 20 | * LOGGER ****************************************************************** 21 | */ 22 | 23 | /** 24 | * @param mixed $message 25 | * @param string $priority 26 | */ 27 | public function log($message, $priority = self::INFO): void // phpcs:ignore 28 | { 29 | // Composite logger 30 | foreach ($this->loggers as $logger) { 31 | $logger->log($message, $priority); 32 | } 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Slack/Formatter/ContextFormatter.php: -------------------------------------------------------------------------------- 1 | setChannel($context->getConfig('channel')); 15 | $context->setUsername($context->getConfig('username', 'Tracy')); 16 | $context->setIconEmoji($context->getConfig('icon_emoji', 'rocket')); 17 | $context->setIconUrl($context->getConfig('icon_emoji', null)); 18 | $context->setText(':bangbang::bangbang::bangbang: Exception occured :bangbang::bangbang::bangbang:'); 19 | $context->setMarkdown(); 20 | 21 | return $context; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/Utils/Utils.php: -------------------------------------------------------------------------------- 1 | renderToFile($exception, $file); 18 | 19 | return $file; 20 | } 21 | 22 | public static function captureException(Throwable $exception, string $file, ?BlueScreen $blueScreen = null): string 23 | { 24 | $bs = $blueScreen ?? new BlueScreen(); 25 | 26 | ob_start(); 27 | $bs->renderToFile($exception, $file); 28 | $contents = ob_get_contents(); 29 | 30 | return (string) $contents; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Slack/Formatter/ColorFormatter.php: -------------------------------------------------------------------------------- 1 | setColor($color); 33 | 34 | return $context; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/Slack/Formatter/ExceptionStackTraceFormatter.php: -------------------------------------------------------------------------------- 1 | getTrace()) < 1) { 14 | return $context; 15 | } 16 | 17 | $context = clone $context; 18 | $attachment = $context->createAttachment(); 19 | $attachment->setText(sprintf('*Stack trace* (_%s_)', get_class($exception))); 20 | $attachment->setMarkdown(); 21 | 22 | foreach ($exception->getTrace() as $id => $trace) { 23 | $func = $attachment->createField(); 24 | $func->setTitle(sprintf(':fireworks: Trace #%s', $id + 1)); 25 | $file = $attachment->createField(); 26 | $file->setTitle(':open_file_folder: File'); 27 | $file->setValue('```Function: ' . $trace['function'] . "\nFile: " . $trace['file'] . ':' . $trace['line'] . '```'); 28 | } 29 | 30 | return $context; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Mailer/FileMailer.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 17 | } 18 | 19 | /** 20 | * @param mixed $message 21 | */ 22 | public function send($message): void 23 | { 24 | /** @var string $host */ 25 | $host = preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n')); 26 | $parts = str_replace( 27 | ["\r\n", "\n"], 28 | ["\n", PHP_EOL], 29 | [ 30 | 'headers' => implode("\n", [ 31 | 'From: file@mailer', 32 | 'X-Mailer: Tracy', 33 | 'Content-Type: text/plain; charset=UTF-8', 34 | 'Content-Transfer-Encoding: 8bit', 35 | ]) . "\n", 36 | 'subject' => 'PHP: An error occurred on the server ' . $host, 37 | 'body' => Logger::formatMessage($message) . "\n\nsource: " . Helpers::getSource(), 38 | ] 39 | ); 40 | 41 | @file_put_contents($this->directory . '/tracy-mail-' . time() . '.txt', implode("\n\n", $parts)); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/FileLogger.php: -------------------------------------------------------------------------------- 1 | .log 13 | */ 14 | class FileLogger extends AbstractLogger implements ILogger 15 | { 16 | 17 | /** 18 | * @param mixed $message 19 | */ 20 | public function log($message, string $priority = ILogger::INFO): void 21 | { 22 | if (!is_dir($this->directory)) { 23 | throw new InvalidStateException('Directory "' . $this->directory . '" is not found or is not directory.'); 24 | } 25 | 26 | $exceptionFile = ($message instanceof Throwable) 27 | ? $this->getExceptionFile($message) 28 | : null; 29 | 30 | $line = Logger::formatLogLine($message, $exceptionFile); 31 | $file = $this->directory . '/' . strtolower($priority) . '.log'; 32 | 33 | if (!(bool) @file_put_contents($file, $line . PHP_EOL, FILE_APPEND | LOCK_EX)) { 34 | throw new InvalidStateException('Unable to write to log file "' . $file . '". Is directory writable?'); 35 | } 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/BlueScreenFileLogger.php: -------------------------------------------------------------------------------- 1 | blueScreen = $blueScreen; 25 | } 26 | 27 | /** 28 | * @param mixed $message 29 | */ 30 | public function log($message, string $priority = ILogger::INFO): void 31 | { 32 | if (!is_dir($this->directory)) { 33 | throw new InvalidStateException('Directory ' . $this->directory . ' is not found or is not directory.'); 34 | } 35 | 36 | if ($message instanceof Throwable) { 37 | Utils::dumpException($message, $this->getExceptionFile($message), $this->blueScreen); 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/Mailer/TracyMailer.php: -------------------------------------------------------------------------------- 1 | from = $from; 26 | $this->to = $to; 27 | } 28 | 29 | /** 30 | * @param mixed $message 31 | */ 32 | public function send($message): void 33 | { 34 | /** @var string $host */ 35 | $host = preg_replace('#[^\w.-]+#', '', $_SERVER['HTTP_HOST'] ?? php_uname('n')); 36 | $parts = str_replace( 37 | ["\r\n", "\n"], 38 | ["\n", PHP_EOL], 39 | [ 40 | 'headers' => implode("\n", [ 41 | 'From: ' . ($this->from ?? 'noreply@' . $host), 42 | 'X-Mailer: Tracy', 43 | 'Content-Type: text/plain; charset=UTF-8', 44 | 'Content-Transfer-Encoding: 8bit', 45 | ]) . "\n", 46 | 'subject' => 'PHP: An error occurred on the server ' . $host, 47 | 'body' => Logger::formatMessage($message) . "\n\nsource: " . Helpers::getSource(), 48 | ] 49 | ); 50 | 51 | $email = implode(',', $this->to); 52 | mail($email, $parts['subject'], $parts['body'], $parts['headers']); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/AbstractLogger.php: -------------------------------------------------------------------------------- 1 | directory = $directory; 20 | } 21 | 22 | public function setDirectory(string $directory): void 23 | { 24 | $this->directory = $directory; 25 | } 26 | 27 | protected function getExceptionFile(Throwable $exception): string 28 | { 29 | $data = []; 30 | 31 | while ($exception) { 32 | $data[] = [ 33 | $exception->getMessage(), 34 | $exception->getCode(), 35 | $exception->getFile(), 36 | $exception->getLine(), 37 | array_map(function ($item): array { 38 | unset($item['args']); 39 | 40 | return $item; 41 | }, $exception->getTrace()), 42 | ]; 43 | $exception = $exception->getPrevious(); 44 | } 45 | 46 | $hash = substr(md5(serialize($data)), 0, 10); 47 | 48 | foreach (new DirectoryIterator($this->directory) as $file) { 49 | if ($file->isDot()) { 50 | continue; 51 | } 52 | 53 | if ((bool) strpos($file->getBasename(), $hash)) { 54 | return $file->getPathname(); 55 | } 56 | } 57 | 58 | return $this->directory . '/exception--' . @date('Y-m-d--H-i') . '--' . $hash . '.html'; // @ timezone may not be set 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/Slack/Formatter/ExceptionPreviousExceptionsFormatter.php: -------------------------------------------------------------------------------- 1 | getPrevious()) !== null) { 15 | $attachment = $context->createAttachment(); 16 | $attachment->setFallback('Required plain-text summary of the attachment.'); 17 | $attachment->setText(sprintf('*Previous exception* (_%s_)', $previous->getMessage())); 18 | $attachment->setMarkdown(); 19 | 20 | $message = $attachment->createField(); 21 | $message->setTitle(':mag_right: Exception'); 22 | $message->setValue(get_class($previous)); 23 | 24 | $message = $attachment->createField(); 25 | $message->setTitle(':envelope: Message'); 26 | $message->setValue($previous->getMessage()); 27 | $message->setShort(); 28 | 29 | $code = $attachment->createField(); 30 | $code->setTitle(':1234: Code'); 31 | $code->setValue((string) $previous->getCode()); 32 | $code->setShort(); 33 | 34 | $file = $attachment->createField(); 35 | $file->setTitle(':open_file_folder: File'); 36 | $file->setValue('```' . $previous->getFile() . ':' . $previous->getLine() . '```'); 37 | 38 | // Change pointer 39 | $exception = $previous; 40 | } 41 | 42 | return $context; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/logging", 3 | "description": "Plug-in support logging for Tracy / Nette Framework", 4 | "keywords": [ 5 | "nette", 6 | "logging", 7 | "tracy", 8 | "monolog", 9 | "plugins" 10 | ], 11 | "type": "library", 12 | "license": "MIT", 13 | "homepage": "https://github.com/contributte/logging", 14 | "authors": [ 15 | { 16 | "name": "Milan Felix Šulc", 17 | "homepage": "https://f3l1x.io" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=7.2", 22 | "tracy/tracy": "~2.5.5|~2.6.2|~2.7.0|~2.8.0|~2.9.0|~2.10.0" 23 | }, 24 | "require-dev": { 25 | "ext-json": "*", 26 | "ninjify/qa": "^0.12", 27 | "ninjify/nunjuck": "^0.4", 28 | "nette/di": "^3.0.0", 29 | "sentry/sdk": "^3.0.0", 30 | "phpstan/phpstan": "^1.0", 31 | "phpstan/phpstan-deprecation-rules": "^1.0", 32 | "phpstan/phpstan-nette": "^1.0", 33 | "phpstan/phpstan-strict-rules": "^1.0" 34 | }, 35 | "conflict": { 36 | "nette/di": "<3.0" 37 | }, 38 | "suggest": { 39 | "nette/di": "to use TracyLoggingExtension", 40 | "sentry/sdk": "to use SentryLoggingExtension" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Contributte\\Logging\\": "src" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Tests\\Helpers\\": "tests/helpers" 50 | } 51 | }, 52 | "minimum-stability": "dev", 53 | "prefer-stable": true, 54 | "extra": { 55 | "branch-alias": { 56 | "dev-master": "0.6.x-dev" 57 | } 58 | }, 59 | "config": { 60 | "allow-plugins": { 61 | "dealerdirect/phpcodesniffer-composer-installer": true 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Slack/Formatter/ExceptionFormatter.php: -------------------------------------------------------------------------------- 1 | createAttachment(); 16 | $attachment->setColor('danger'); 17 | $attachment->setMarkdown(); 18 | 19 | $message = $attachment->createField(); 20 | $message->setTitle(':date: Date'); 21 | $message->setValue(@date('[d.m.Y]')); 22 | $message->setShort(); 23 | 24 | $message = $attachment->createField(); 25 | $message->setTitle(':timer_clock: Time'); 26 | $message->setValue(@date('[H:i:s]')); 27 | $message->setShort(); 28 | 29 | $message = $attachment->createField(); 30 | $message->setTitle(':computer: Source'); 31 | $message->setValue(Helpers::getSource()); 32 | 33 | $message = $attachment->createField(); 34 | $message->setTitle(':mag_right: Exception'); 35 | $message->setValue(get_class($exception)); 36 | 37 | $message = $attachment->createField(); 38 | $message->setTitle(':envelope: Message'); 39 | $message->setValue($exception->getMessage()); 40 | $message->setShort(); 41 | 42 | $code = $attachment->createField(); 43 | $code->setTitle(':1234: Code'); 44 | $code->setValue((string) $exception->getCode()); 45 | $code->setShort(); 46 | 47 | $file = $attachment->createField(); 48 | $file->setTitle(':open_file_folder: File'); 49 | $file->setValue('```' . $exception->getFile() . ':' . $exception->getLine() . '```'); 50 | 51 | return $context; 52 | } 53 | 54 | } 55 | -------------------------------------------------------------------------------- /src/DI/SentryLoggingExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 24 | 'enabled' => Expect::bool(true), 25 | 'options' => Expect::array(), 26 | ]); 27 | } 28 | 29 | /** 30 | * Register services 31 | */ 32 | public function loadConfiguration(): void 33 | { 34 | $builder = $this->getContainerBuilder(); 35 | $config = $this->config; 36 | 37 | if ($config->enabled === false) { 38 | return; 39 | } 40 | 41 | $builder->addDefinition($this->prefix('logger')) 42 | ->setFactory(SentryLogger::class, [(array) $config]); 43 | } 44 | 45 | /** 46 | * Decorate services 47 | */ 48 | public function beforeCompile(): void 49 | { 50 | $builder = $this->getContainerBuilder(); 51 | $config = $this->config; 52 | 53 | if ($config->enabled === false) { 54 | return; 55 | } 56 | 57 | $logger = $builder->getByType(UniversalLogger::class); 58 | 59 | if ($logger === null) { 60 | throw new ServiceCreationException( 61 | sprintf( 62 | 'Service "%s" is required. Did you register %s extension as well?', 63 | UniversalLogger::class, 64 | TracyLoggingExtension::class 65 | ) 66 | ); 67 | } 68 | 69 | $def = $builder->getDefinition($logger); 70 | assert($def instanceof ServiceDefinition); 71 | $def->addSetup('addLogger', ['@' . $this->prefix('logger')]); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/SendMailLogger.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 24 | } 25 | 26 | /** 27 | * @param string[] $allowedPriority 28 | */ 29 | public function setAllowedPriority(array $allowedPriority): void 30 | { 31 | $this->allowedPriority = $allowedPriority; 32 | } 33 | 34 | public function setEmailSnooze(string $emailSnooze): void 35 | { 36 | $this->emailSnooze = $emailSnooze; 37 | } 38 | 39 | public function setMailer(IMailer $mailer): void 40 | { 41 | $this->mailer = $mailer; 42 | } 43 | 44 | /** 45 | * @param mixed $message 46 | */ 47 | public function log($message, string $priority = ILogger::INFO): void 48 | { 49 | if (!in_array($priority, $this->allowedPriority, true)) { 50 | return; 51 | } 52 | 53 | if (is_numeric($this->emailSnooze)) { 54 | $snooze = (int) $this->emailSnooze; 55 | } else { 56 | $strtotime = @strtotime($this->emailSnooze); 57 | 58 | if ($strtotime === false) { 59 | throw new InvalidArgumentException('Email snooze was not parsed'); 60 | } 61 | 62 | $snooze = $strtotime - time(); 63 | } 64 | 65 | $filemtime = @filemtime($this->directory . '/email-sent'); 66 | 67 | if ($filemtime === false) { 68 | $filemtime = 0; 69 | } 70 | 71 | if ($filemtime + $snooze < time() && (bool) @file_put_contents($this->directory . '/email-sent', 'sent') 72 | ) { 73 | $this->mailer->send($message); 74 | } 75 | } 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/DI/TracyLoggingExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 23 | 'loggers' => Expect::listOf('array|string|Nette\DI\Definitions\Statement'), 24 | ]); 25 | } 26 | 27 | /** 28 | * Register services 29 | */ 30 | public function loadConfiguration(): void 31 | { 32 | $builder = $this->getContainerBuilder(); 33 | $config = $this->config; 34 | 35 | $logger = $builder->addDefinition($this->prefix('logger')) 36 | ->setType(UniversalLogger::class); 37 | 38 | // Register defined loggers 39 | if (count($config->loggers) !== 0) { 40 | $loggers = []; 41 | 42 | foreach ($config->loggers as $k => $v) { 43 | $loggers[$this->prefix('logger.' . $k)] = $v; 44 | } 45 | 46 | $this->compiler->loadDefinitionsFromConfig($loggers); 47 | 48 | foreach (array_keys($loggers) as $name) { 49 | $logger->addSetup('addLogger', [$builder->getDefinition($name)]); 50 | } 51 | 52 | return; 53 | } 54 | 55 | // Register default loggers 56 | $fileLogger = $builder->addDefinition($this->prefix('logger.filelogger')) 57 | ->setFactory(FileLogger::class, [$config->logDir]) 58 | ->setAutowired('self'); 59 | 60 | $blueScreenFileLogger = $builder->addDefinition($this->prefix('logger.bluescreenfilelogger')) 61 | ->setFactory(BlueScreenFileLogger::class, [$config->logDir]) 62 | ->setAutowired('self'); 63 | 64 | $logger->addSetup('addLogger', [$fileLogger]); 65 | $logger->addSetup('addLogger', [$blueScreenFileLogger]); 66 | } 67 | 68 | /** 69 | * Decorate services 70 | */ 71 | public function beforeCompile(): void 72 | { 73 | $builder = $this->getContainerBuilder(); 74 | 75 | // Replace tracy default logger for ours 76 | if ($builder->hasDefinition('tracy.logger')) { 77 | $builder->addDefinition($this->prefix('originalLogger'), clone $builder->getDefinition('tracy.logger')) 78 | ->setAutowired(false); 79 | 80 | $builder->removeDefinition('tracy.logger'); 81 | $builder->addAlias('tracy.logger', $this->prefix('logger')); 82 | } 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/Slack/SlackLogger.php: -------------------------------------------------------------------------------- 1 | config = $config; 27 | } 28 | 29 | public function addFormatter(IFormatter $formatter): void 30 | { 31 | $this->formatters[] = $formatter; 32 | } 33 | 34 | /** 35 | * @param mixed $message 36 | */ 37 | public function log($message, string $priority = ILogger::INFO): void 38 | { 39 | if (!in_array($priority, [ILogger::ERROR, ILogger::EXCEPTION, ILogger::CRITICAL], true)) { 40 | return; 41 | } 42 | 43 | if (!($message instanceof Throwable)) { 44 | return; 45 | } 46 | 47 | $context = new SlackContext($this->config); 48 | 49 | // Apply all formatters 50 | foreach ($this->formatters as $formatter) { 51 | $context = $formatter->format($context, $message, $priority); 52 | } 53 | 54 | // Send to channel 55 | $this->makeRequest($context); 56 | } 57 | 58 | protected function makeRequest(SlackContext $context): void 59 | { 60 | $url = $this->get('url'); 61 | 62 | $streamcontext = [ 63 | 'http' => [ 64 | 'method' => 'POST', 65 | 'header' => 'Content-type: application/x-www-form-urlencoded', 66 | 'timeout' => $this->get('timeout', 30), 67 | 'content' => http_build_query([ 68 | 'payload' => json_encode(array_filter($context->toArray())), 69 | ]), 70 | ], 71 | ]; 72 | 73 | $response = @file_get_contents($url, false, stream_context_create($streamcontext)); 74 | 75 | if ($response !== 'ok') { 76 | throw new SlackBadRequestException([ 77 | 'url' => $url, 78 | 'context' => $streamcontext, 79 | 'response' => [ 80 | 'headers' => $http_response_header, 81 | ], 82 | ]); 83 | } 84 | } 85 | 86 | /** 87 | * @param mixed $default 88 | * @return mixed 89 | */ 90 | protected function get(string $key, $default = null) 91 | { 92 | return func_num_args() > 1 93 | ? Arrays::get($this->config, explode('.', $key), $default) 94 | : Arrays::get($this->config, explode('.', $key)); 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/Slack/Formatter/SlackContextAttachment.php: -------------------------------------------------------------------------------- 1 | data['fallback'] = $fallback; 17 | } 18 | 19 | public function setColor(string $color): void 20 | { 21 | $this->data['color'] = $color; 22 | } 23 | 24 | public function setPretext(string $pretext): void 25 | { 26 | $this->data['pretext'] = $pretext; 27 | } 28 | 29 | public function setText(string $text): void 30 | { 31 | $this->data['text'] = $text; 32 | } 33 | 34 | public function setTitle(string $title): void 35 | { 36 | $this->data['title'] = $title; 37 | } 38 | 39 | public function setTitleLink(string $link): void 40 | { 41 | $this->data['title_link'] = $link; 42 | } 43 | 44 | public function setAuthorName(string $name): void 45 | { 46 | $this->data['author_name'] = $name; 47 | } 48 | 49 | public function setAuthorLink(string $link): void 50 | { 51 | $this->data['author_link'] = $link; 52 | } 53 | 54 | public function setAuthorIcon(string $icon): void 55 | { 56 | $this->data['author_icon'] = $icon; 57 | } 58 | 59 | public function setImageUrl(string $url): void 60 | { 61 | $this->data['image_url'] = $url; 62 | } 63 | 64 | public function setThumbUrl(string $url): void 65 | { 66 | $this->data['thumb_url'] = $url; 67 | } 68 | 69 | public function setFooter(string $footer): void 70 | { 71 | $this->data['footer'] = $footer; 72 | } 73 | 74 | public function setFooterIcon(string $icon): void 75 | { 76 | $this->data['footer_icon'] = $icon; 77 | } 78 | 79 | public function setTimestamp(string $timestamp): void 80 | { 81 | $this->data['ts'] = $timestamp; 82 | } 83 | 84 | public function setMarkdown(bool $markdown = true): void 85 | { 86 | if ($markdown) { 87 | $this->data['mrkdwn_in'] = ['pretext', 'text', 'fields']; 88 | } 89 | } 90 | 91 | public function createField(): SlackContextField 92 | { 93 | return $this->fields[] = new SlackContextField(); 94 | } 95 | 96 | /** 97 | * @return mixed[] 98 | */ 99 | public function toArray(): array 100 | { 101 | $data = $this->data; 102 | 103 | if (count($this->fields) > 0) { 104 | $data['fields'] = []; 105 | 106 | foreach ($this->fields as $attachment) { 107 | $data['fields'][] = $attachment->toArray(); 108 | } 109 | } 110 | 111 | return $data; 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /src/Slack/Formatter/SlackContext.php: -------------------------------------------------------------------------------- 1 | config = $config; 28 | } 29 | 30 | /** 31 | * @param mixed $default 32 | * @return mixed 33 | */ 34 | public function getConfig(string $key, $default = null) 35 | { 36 | return func_num_args() > 1 37 | ? Arrays::get($this->config, explode('.', $key), $default) 38 | : Arrays::get($this->config, explode('.', $key)); 39 | } 40 | 41 | public function setChannel(string $channel): void 42 | { 43 | $this->data['channel'] = $channel; 44 | } 45 | 46 | public function setUsername(string $username): void 47 | { 48 | $this->data['username'] = $username; 49 | } 50 | 51 | public function setIconEmoji(string $icon): void 52 | { 53 | $this->data['icon_emoji'] = sprintf(':%s:', trim($icon, ':')); 54 | } 55 | 56 | public function setIconUrl(string $icon): void 57 | { 58 | $this->data['icon_url'] = $icon; 59 | } 60 | 61 | public function setText(string $text): void 62 | { 63 | $this->data['text'] = $text; 64 | } 65 | 66 | public function setColor(string $color): void 67 | { 68 | $this->data['color'] = $color; 69 | } 70 | 71 | public function setMarkdown(bool $markdown = true): void 72 | { 73 | $this->data['mrkdwn'] = $markdown; 74 | } 75 | 76 | public function createField(): SlackContextField 77 | { 78 | return $this->fields[] = new SlackContextField(); 79 | } 80 | 81 | public function createAttachment(): SlackContextAttachment 82 | { 83 | return $this->attachments[] = new SlackContextAttachment(); 84 | } 85 | 86 | /** 87 | * @return mixed[] 88 | */ 89 | public function toArray(): array 90 | { 91 | $data = $this->data; 92 | 93 | if (count($this->fields) > 0) { 94 | $data['fields'] = []; 95 | 96 | foreach ($this->fields as $attachment) { 97 | $data['fields'][] = $attachment->toArray(); 98 | } 99 | } 100 | 101 | if (count($this->attachments) > 0) { 102 | $data['attachments'] = []; 103 | 104 | foreach ($this->attachments as $attachment) { 105 | $data['attachments'][] = $attachment->toArray(); 106 | } 107 | } 108 | 109 | return $data; 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/DI/SlackLoggingExtension.php: -------------------------------------------------------------------------------- 1 | Expect::string()->required(), 29 | 'channel' => Expect::string()->required(), 30 | 'username' => Expect::string('Tracy'), 31 | 'icon_emoji' => Expect::string(':rocket:'), 32 | 'icon_url' => Expect::string()->nullable(), 33 | 'formatters' => Expect::listOf('array|string|Nette\DI\Definitions\Statement')->default([ 34 | ContextFormatter::class, 35 | ColorFormatter::class, 36 | ExceptionFormatter::class, 37 | ExceptionStackTraceFormatter::class, 38 | ExceptionPreviousExceptionsFormatter::class, 39 | ]), 40 | ]); 41 | } 42 | 43 | /** 44 | * Register services 45 | */ 46 | public function loadConfiguration(): void 47 | { 48 | $builder = $this->getContainerBuilder(); 49 | $config = $this->config; 50 | 51 | $loggerSlack = $builder->addDefinition($this->prefix('logger')) 52 | ->setFactory(SlackLogger::class, [(array) $config]); 53 | 54 | foreach ($config->formatters as $n => $formatter) { 55 | $def = $builder->addDefinition($this->prefix('formatter.' . ($n + 1))) 56 | ->setType($formatter); 57 | 58 | $loggerSlack->addSetup('addFormatter', [$def]); 59 | } 60 | } 61 | 62 | /** 63 | * Decorate services 64 | */ 65 | public function beforeCompile(): void 66 | { 67 | $builder = $this->getContainerBuilder(); 68 | 69 | $logger = $builder->getByType(UniversalLogger::class); 70 | 71 | if ($logger === null) { 72 | throw new ServiceCreationException( 73 | sprintf( 74 | 'Service "%s" is required. Did you register %s extension as well?', 75 | UniversalLogger::class, 76 | TracyLoggingExtension::class 77 | ) 78 | ); 79 | } 80 | 81 | $def = $builder->getDefinition($logger); 82 | assert($def instanceof ServiceDefinition); 83 | $def->addSetup('addLogger', ['@' . $this->prefix('logger')]); 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/Sentry/SentryLogger.php: -------------------------------------------------------------------------------- 1 | Severity::DEBUG, 18 | self::INFO => Severity::INFO, 19 | self::WARNING => Severity::WARNING, 20 | self::ERROR => Severity::ERROR, 21 | self::EXCEPTION => Severity::FATAL, 22 | self::CRITICAL => Severity::FATAL, 23 | ]; 24 | 25 | public const CONFIG_URL = 'url'; 26 | public const CONFIG_OPTIONS = 'options'; 27 | 28 | /** @var mixed[] */ 29 | protected $configuration; 30 | 31 | /** @var string[] */ 32 | private $allowedPriority = [ILogger::ERROR, ILogger::EXCEPTION, ILogger::CRITICAL]; 33 | 34 | /** 35 | * @param mixed[] $configuration 36 | */ 37 | public function __construct(array $configuration) 38 | { 39 | if (!isset($configuration[self::CONFIG_URL])) { 40 | throw new InvalidStateException('Missing url in SentryLogger configuration'); 41 | } 42 | 43 | if (!isset($configuration[self::CONFIG_OPTIONS])) { 44 | $configuration[self::CONFIG_OPTIONS] = []; 45 | } 46 | 47 | $this->configuration = $configuration; 48 | } 49 | 50 | /** 51 | * @param string[] $allowedPriority 52 | */ 53 | public function setAllowedPriority(array $allowedPriority): void 54 | { 55 | $this->allowedPriority = $allowedPriority; 56 | } 57 | 58 | /** 59 | * @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingParameterTypeHint 60 | * @phpcsSuppress SlevomatCodingStandard.TypeHints.TypeHintDeclaration.MissingReturnTypeHint 61 | * @param mixed $message 62 | */ 63 | public function log($message, string $priority = ILogger::INFO): void 64 | { 65 | if (!in_array($priority, $this->allowedPriority, true)) { 66 | return; 67 | } 68 | 69 | $level = $this->getLevel($priority); 70 | 71 | if ($level === null) { 72 | return; 73 | } 74 | 75 | $scope = (new Scope())->setLevel(new Severity($level)); 76 | 77 | $this->makeRequest($message, $scope); 78 | } 79 | 80 | /** 81 | * @param mixed $message 82 | */ 83 | protected function makeRequest($message, Scope $scope): void 84 | { 85 | $client = ClientBuilder::create($this->configuration[self::CONFIG_OPTIONS] + ['dsn' => $this->configuration[self::CONFIG_URL]]) 86 | ->getClient(); 87 | SentrySdk::init()->bindClient($client); 88 | 89 | if ($message instanceof Throwable) { 90 | $client->captureException($message, $scope); 91 | } else { 92 | $client->captureMessage($message, null, $scope); 93 | } 94 | } 95 | 96 | protected function getLevel(string $priority): ?string 97 | { 98 | return self::LEVEL_PRIORITY_MAP[$priority] ?? null; 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: "build" 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - ".docs/**" 7 | push: 8 | branches: 9 | - "*" 10 | schedule: 11 | - cron: "0 8 * * 1" # At 08:00 on Monday 12 | 13 | env: 14 | extensions: "json" 15 | cache-version: "1" 16 | composer-version: "v2" 17 | composer-install: "composer update --no-interaction --no-progress --prefer-dist --prefer-stable" 18 | 19 | jobs: 20 | qa: 21 | name: "Quality assurance" 22 | runs-on: "${{ matrix.operating-system }}" 23 | 24 | strategy: 25 | matrix: 26 | php-version: [ "7.4" ] 27 | operating-system: [ "ubuntu-latest" ] 28 | fail-fast: false 29 | 30 | steps: 31 | - name: "Checkout" 32 | uses: "actions/checkout@v2" 33 | 34 | - name: "Setup PHP cache environment" 35 | id: "extcache" 36 | uses: "shivammathur/cache-extensions@v1" 37 | with: 38 | php-version: "${{ matrix.php-version }}" 39 | extensions: "${{ env.extensions }}" 40 | key: "${{ env.cache-version }}" 41 | 42 | - name: "Cache PHP extensions" 43 | uses: "actions/cache@v2" 44 | with: 45 | path: "${{ steps.extcache.outputs.dir }}" 46 | key: "${{ steps.extcache.outputs.key }}" 47 | restore-keys: "${{ steps.extcache.outputs.key }}" 48 | 49 | - name: "Install PHP" 50 | uses: "shivammathur/setup-php@v2" 51 | with: 52 | php-version: "${{ matrix.php-version }}" 53 | extensions: "${{ env.extensions }}" 54 | tools: "composer:${{ env.composer-version }} " 55 | 56 | - name: "Setup problem matchers for PHP" 57 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 58 | 59 | - name: "Get Composer cache directory" 60 | id: "composercache" 61 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 62 | 63 | - name: "Cache PHP dependencies" 64 | uses: "actions/cache@v2" 65 | with: 66 | path: "${{ steps.composercache.outputs.dir }}" 67 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 68 | restore-keys: "${{ runner.os }}-composer-" 69 | 70 | - name: "Validate Composer" 71 | run: "composer validate" 72 | 73 | - name: "Install dependencies" 74 | run: "${{ env.composer-install }}" 75 | 76 | - name: "Coding Standard" 77 | run: "make cs" 78 | 79 | static-analysis: 80 | name: "Static analysis" 81 | runs-on: "${{ matrix.operating-system }}" 82 | 83 | strategy: 84 | matrix: 85 | php-version: [ "7.4" ] 86 | operating-system: [ "ubuntu-latest" ] 87 | fail-fast: false 88 | 89 | steps: 90 | - name: "Checkout" 91 | uses: "actions/checkout@v2" 92 | 93 | - name: "Setup PHP cache environment" 94 | id: "extcache" 95 | uses: "shivammathur/cache-extensions@v1" 96 | with: 97 | php-version: "${{ matrix.php-version }}" 98 | extensions: "${{ env.extensions }}" 99 | key: "${{ env.cache-version }}" 100 | 101 | - name: "Cache PHP extensions" 102 | uses: "actions/cache@v2" 103 | with: 104 | path: "${{ steps.extcache.outputs.dir }}" 105 | key: "${{ steps.extcache.outputs.key }}" 106 | restore-keys: "${{ steps.extcache.outputs.key }}" 107 | 108 | - name: "Install PHP" 109 | uses: "shivammathur/setup-php@v2" 110 | with: 111 | php-version: "${{ matrix.php-version }}" 112 | extensions: "${{ env.extensions }}" 113 | tools: "composer:${{ env.composer-version }} " 114 | 115 | - name: "Setup problem matchers for PHP" 116 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 117 | 118 | - name: "Get Composer cache directory" 119 | id: "composercache" 120 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 121 | 122 | - name: "Cache PHP dependencies" 123 | uses: "actions/cache@v2" 124 | with: 125 | path: "${{ steps.composercache.outputs.dir }}" 126 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 127 | restore-keys: "${{ runner.os }}-composer-" 128 | 129 | - name: "Install dependencies" 130 | run: "${{ env.composer-install }}" 131 | 132 | - name: "PHPStan" 133 | run: "make phpstan" 134 | 135 | tests: 136 | name: "Tests" 137 | runs-on: "${{ matrix.operating-system }}" 138 | 139 | strategy: 140 | matrix: 141 | php-version: [ "7.2", "7.3", "7.4" ] 142 | operating-system: [ "ubuntu-latest" ] 143 | composer-args: [ "" ] 144 | include: 145 | - php-version: "7.2" 146 | operating-system: "ubuntu-latest" 147 | composer-args: "--prefer-lowest" 148 | - php-version: "8.0" 149 | operating-system: "ubuntu-latest" 150 | composer-args: "" 151 | fail-fast: false 152 | 153 | continue-on-error: "${{ matrix.php-version == '8.0' }}" 154 | 155 | steps: 156 | - name: "Checkout" 157 | uses: "actions/checkout@v2" 158 | 159 | - name: "Setup PHP cache environment" 160 | id: "extcache" 161 | uses: "shivammathur/cache-extensions@v1" 162 | with: 163 | php-version: "${{ matrix.php-version }}" 164 | extensions: "${{ env.extensions }}" 165 | key: "${{ env.cache-version }}" 166 | 167 | - name: "Cache PHP extensions" 168 | uses: "actions/cache@v2" 169 | with: 170 | path: "${{ steps.extcache.outputs.dir }}" 171 | key: "${{ steps.extcache.outputs.key }}" 172 | restore-keys: "${{ steps.extcache.outputs.key }}" 173 | 174 | - name: "Install PHP" 175 | uses: "shivammathur/setup-php@v2" 176 | with: 177 | php-version: "${{ matrix.php-version }}" 178 | extensions: "${{ env.extensions }}" 179 | tools: "composer:${{ env.composer-version }} " 180 | 181 | - name: "Setup problem matchers for PHP" 182 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 183 | 184 | - name: "Get Composer cache directory" 185 | id: "composercache" 186 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 187 | 188 | - name: "Cache PHP dependencies" 189 | uses: "actions/cache@v2" 190 | with: 191 | path: "${{ steps.composercache.outputs.dir }}" 192 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 193 | restore-keys: "${{ runner.os }}-composer-" 194 | 195 | - name: "Install dependencies" 196 | run: "${{ env.composer-install }} ${{ matrix.composer-args }}" 197 | 198 | - name: "Setup problem matchers for PHPUnit" 199 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"' 200 | 201 | - name: "Tests" 202 | run: "make tests" 203 | 204 | tests-code-coverage: 205 | name: "Tests with code coverage" 206 | runs-on: "${{ matrix.operating-system }}" 207 | 208 | strategy: 209 | matrix: 210 | php-version: [ "7.4" ] 211 | operating-system: [ "ubuntu-latest" ] 212 | fail-fast: false 213 | 214 | if: "github.event_name == 'push'" 215 | 216 | steps: 217 | - name: "Checkout" 218 | uses: "actions/checkout@v2" 219 | 220 | - name: "Setup PHP cache environment" 221 | id: "extcache" 222 | uses: "shivammathur/cache-extensions@v1" 223 | with: 224 | php-version: "${{ matrix.php-version }}" 225 | extensions: "${{ env.extensions }}" 226 | key: "${{ env.cache-version }}" 227 | 228 | - name: "Cache PHP extensions" 229 | uses: "actions/cache@v2" 230 | with: 231 | path: "${{ steps.extcache.outputs.dir }}" 232 | key: "${{ steps.extcache.outputs.key }}" 233 | restore-keys: "${{ steps.extcache.outputs.key }}" 234 | 235 | - name: "Install PHP" 236 | uses: "shivammathur/setup-php@v2" 237 | with: 238 | php-version: "${{ matrix.php-version }}" 239 | extensions: "${{ env.extensions }}" 240 | tools: "composer:${{ env.composer-version }} " 241 | 242 | - name: "Setup problem matchers for PHP" 243 | run: 'echo "::add-matcher::${{ runner.tool_cache }}/php.json"' 244 | 245 | - name: "Get Composer cache directory" 246 | id: "composercache" 247 | run: 'echo "::set-output name=dir::$(composer config cache-files-dir)"' 248 | 249 | - name: "Cache PHP dependencies" 250 | uses: "actions/cache@v2" 251 | with: 252 | path: "${{ steps.composercache.outputs.dir }}" 253 | key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }}" 254 | restore-keys: "${{ runner.os }}-composer-" 255 | 256 | - name: "Install dependencies" 257 | run: "${{ env.composer-install }}" 258 | 259 | - name: "Tests" 260 | run: "make coverage-clover" 261 | 262 | - name: "Coveralls.io" 263 | env: 264 | CI_NAME: github 265 | CI: true 266 | COVERALLS_REPO_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 267 | run: | 268 | wget https://github.com/php-coveralls/php-coveralls/releases/download/v2.1.0/php-coveralls.phar 269 | php php-coveralls.phar --verbose --config tests/.coveralls.yml 270 | --------------------------------------------------------------------------------