diff --git a/composer.json b/composer.json index 303dfbe..97d1c32 100644 --- a/composer.json +++ b/composer.json @@ -46,6 +46,7 @@ "php-http/message": "^1.15", "psr/container": "^1.1 || ^2.0", "psr/http-message": "^1.1 || ^2", + "psr/log": "^1 || ^2 || ^3", "symfony/console": "^6.4 || ^7 || ^8", "symfony/var-dumper": "^6.3 || ^7 || ^8", "yiisoft/injector": "^1.2" diff --git a/src/Client/TrapHandle.php b/src/Client/TrapHandle.php index a4ad83f..0363b17 100644 --- a/src/Client/TrapHandle.php +++ b/src/Client/TrapHandle.php @@ -8,6 +8,8 @@ use Buggregator\Trap\Client\TrapHandle\Counter; use Buggregator\Trap\Client\TrapHandle\Dumper as VarDumper; use Buggregator\Trap\Client\TrapHandle\StaticState; +use Buggregator\Trap\Log\TrapLogger; +use Psr\Log\LoggerInterface; use Symfony\Component\VarDumper\Caster\TraceStub; /** @@ -20,6 +22,7 @@ final class TrapHandle private string $timesCounterKey = ''; private int $depth = 0; private readonly StaticState $staticState; + private static ?TrapLogger $logger = null; private function __construct( private array $values, @@ -35,6 +38,33 @@ public static function fromArray(array $array): self return new self($array); } + /** + * Get a PSR-3 compatible logger instance for Trap client logging. + * + * Uses TRAP_MONOLOG_HOST and TRAP_MONOLOG_PORT env variables, + * falling back to 127.0.0.1:9913. + */ + public static function logger(): LoggerInterface + { + if (self::$logger === null) { + $host = self::getEnvValue('TRAP_MONOLOG_HOST', '127.0.0.1'); + $port = self::getEnvValue('TRAP_MONOLOG_PORT', '9913'); + + $port = \is_numeric($port) ? (int) $port : 9913; + + if ($port < 1 || $port > 65535) { + $port = 9913; + } + + self::$logger = new TrapLogger( + host: $host, + port: $port, + ); + } + + return self::$logger; + } + /** * Create a new instance with a single value. * @@ -214,6 +244,25 @@ public function __destruct() $this->haveToSend() and $this->sendDump(); } + private static function getEnvValue(string $name, string $default): string + { + if (\array_key_exists($name, $_ENV)) { + return $_ENV[$name]; + } + + $value = \getenv($name, true); + if ($value !== false) { + return $value; + } + + $value = \getenv($name); + if ($value !== false) { + return $value; + } + + return $default; + } + private function sendDump(): void { $staticState = StaticState::getValue(); @@ -223,9 +272,9 @@ private function sendDump(): void try { // Set default values if not set if (!isset($_SERVER['VAR_DUMPER_FORMAT'], $_SERVER['VAR_DUMPER_SERVER'])) { - $_SERVER['VAR_DUMPER_FORMAT'] = $this->getEnvValue('VAR_DUMPER_FORMAT', 'server'); + $_SERVER['VAR_DUMPER_FORMAT'] = self::getEnvValue('VAR_DUMPER_FORMAT', 'server'); // todo use the config file in the future - $_SERVER['VAR_DUMPER_SERVER'] = $this->getEnvValue('VAR_DUMPER_SERVER', '127.0.0.1:9912'); + $_SERVER['VAR_DUMPER_SERVER'] = self::getEnvValue('VAR_DUMPER_SERVER', '127.0.0.1:9912'); } // Dump single value @@ -248,25 +297,6 @@ private function sendDump(): void } } - private function getEnvValue(string $name, string $default): string - { - if (\array_key_exists($name, $_ENV)) { - return $_ENV[$name]; - } - - $value = \getenv($name, true); - if ($value !== false) { - return $value; - } - - $value = \getenv($name); - if ($value !== false) { - return $value; - } - - return $default; - } - private function haveToSend(): bool { if (!$this->haveToSend || $this->values === []) { diff --git a/src/Log/TrapLogger.php b/src/Log/TrapLogger.php new file mode 100644 index 0000000..19581d0 --- /dev/null +++ b/src/Log/TrapLogger.php @@ -0,0 +1,291 @@ +, + * level: int, + * level_name: string, + * channel: string, + * datetime: string, + * extra: array + * } + */ +final class TrapLogger extends AbstractLogger +{ + private const ASYNC_WRITE_RETRY_COUNT = 3; + private const ASYNC_WRITE_RETRY_DELAY_MICROSECONDS = 5_000; + + public function __construct( + private string $host = '127.0.0.1', + private int $port = 9913, + private string $channel = 'trap', + private float $connectTimeout = 0.5, + ) {} + + /** + * Sends a log record to the Trap server in Monolog-compatible JSON format, + * falling back to STDERR if the connection fails. + */ + public function log(mixed $level, string|\Stringable $message, array $context = []): void + { + $level = \strtolower((string) $level); + + $record = $this->createRecord($level, $message, $context); + + $payload = \json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($payload === false) { + $this->writeFallback($record); + + return; + } + + if (!$this->sendToTrap($payload)) { + $this->writeFallback($record); + } + } + + /** + * Routes the payload to the appropriate send method + * based on whether we are currently inside a Fiber. + */ + private function sendToTrap(string $payload): bool + { + return \Fiber::getCurrent() === null + ? $this->sendSync($payload) + : $this->sendAsync($payload); + } + + /** + * Sends the payload synchronously using a blocking TCP connection. + */ + private function sendSync(string $payload): bool + { + $address = \sprintf('tcp://%s:%d', $this->host, $this->port); + + $errorCode = 0; + $errorMessage = ''; + + $stream = @\stream_socket_client( + $address, + $errorCode, + $errorMessage, + $this->connectTimeout, + ); + + if ($stream === false) { + return false; + } + + @\stream_set_timeout($stream, (int) $this->connectTimeout, (int) ($this->connectTimeout * 1000000 % 1000000)); + + try { + $payload .= "\n"; + $offset = 0; + $length = \strlen($payload); + + while ($offset < $length) { + $written = @\fwrite($stream, \substr($payload, $offset)); + + if (!\is_int($written) || $written <= 0) { + return false; + } + + $offset += $written; + } + + return true; + } finally { + \fclose($stream); + } + } + + /** + * Sends the payload using a non-blocking TCP connection. + */ + private function sendAsync(string $payload): bool + { + $address = \sprintf('tcp://%s:%d', $this->host, $this->port); + + $errorCode = 0; + $errorMessage = ''; + + $stream = @\stream_socket_client( + $address, + $errorCode, + $errorMessage, + $this->connectTimeout, + STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT, + ); + + if ($stream === false) { + return false; + } + + @\stream_set_blocking($stream, false); + + try { + $payload .= "\n"; + $offset = 0; + $length = \strlen($payload); + + while ($offset < $length) { + $isWritable = false; + + for ($attempt = 0; $attempt < self::ASYNC_WRITE_RETRY_COUNT; $attempt++) { + $write = [$stream]; + $read = []; + $except = []; + + $result = @\stream_select($read, $write, $except, 0, self::ASYNC_WRITE_RETRY_DELAY_MICROSECONDS); + + if ($result === 1) { + $isWritable = true; + break; + } + + if ($result === false) { + return false; + } + } + + if (!$isWritable) { + return false; + } + + $written = @\fwrite($stream, \substr($payload, $offset)); + + if (!\is_int($written) || $written <= 0) { + return false; + } + + $offset += $written; + } + + return true; + } finally { + \fclose($stream); + } + } + + /** + * Replaces {placeholder} tokens in the message with values from context. + * Follows PSR-3 placeholder interpolation rules. + * + * @param array $context + */ + private function interpolate(string $message, array $context): string + { + if ($context === [] || !\str_contains($message, '{')) { + return $message; + } + + $replacements = []; + + /** @psalm-suppress MixedAssignment */ + foreach ($context as $key => $value) { + if (\is_object($value) && !\method_exists($value, '__toString')) { + $replacements['{' . (string) $key . '}'] = \get_class($value); + + continue; + } + + if (\is_array($value)) { + $valueJson = \json_encode($value, JSON_UNESCAPED_UNICODE); + + $replacements['{' . (string) $key . '}'] = $valueJson !== false + ? $valueJson + : '[unserializable array]'; + + continue; + } + + $replacements['{' . (string) $key . '}'] = (string) $value; + } + + return \strtr($message, $replacements); + } + + /** + * @param LogRecord $record + */ + private function writeFallback(array $record): void + { + $line = $this->formatFallbackLine($record); + + if (\defined('STDERR')) { + \fwrite(\STDERR, $line . \PHP_EOL); + } else { + \error_log($line); + } + } + + /** + * @param LogRecord $record + */ + private function formatFallbackLine(array $record): string + { + $contextJson = \json_encode($record['context'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return \sprintf( + '[%s] %s.%s: %s %s', + $record['datetime'], + $record['channel'], + $record['level_name'], + $record['message'], + $contextJson !== false ? $contextJson : '[unserializable context]', + ); + } + + /** + * Builds a Monolog-compatible log record array from the given level, message and context. + * + * @param array $context + * @return LogRecord + */ + private function createRecord(string $level, string|\Stringable $message, array $context): array + { + $interpolated = $this->interpolate((string) $message, $context); + + return [ + 'message' => $interpolated, + 'context' => $context, + 'level' => $this->mapLevelToInt($level), + 'level_name' => \strtoupper($level), + 'channel' => $this->channel, + 'datetime' => (new \DateTimeImmutable())->format(\DateTimeInterface::RFC3339_EXTENDED), + 'extra' => [], + ]; + } + + /** + * Map PSR-3 level name to Monolog-style integer level. + */ + private function mapLevelToInt(string $level): int + { + $levelMap = [ + 'debug' => 100, + 'info' => 200, + 'notice' => 250, + 'warning' => 300, + 'error' => 400, + 'critical' => 500, + 'alert' => 550, + 'emergency' => 600, + ]; + + if (!isset($levelMap[$level])) { + throw new InvalidArgumentException(\sprintf('Invalid log level "%s".', $level)); + } + + return $levelMap[$level]; + } +} diff --git a/tests/Unit/Client/TrapLoggerTest.php b/tests/Unit/Client/TrapLoggerTest.php new file mode 100644 index 0000000..176b5f8 --- /dev/null +++ b/tests/Unit/Client/TrapLoggerTest.php @@ -0,0 +1,135 @@ +info('Hello {name}', ['name' => 'Trap']); + + $client = @\stream_socket_accept($server, 1); + self::assertNotFalse($client); + + $line = \stream_get_line($client, 8192, "\n"); + self::assertNotFalse($line); + + $payload = \json_decode($line, true); + + self::assertSame('Hello Trap', $payload['message']); + self::assertSame('INFO', $payload['level_name']); + self::assertSame(['name' => 'Trap'], $payload['context']); + } finally { + if (\is_resource($client ?? null)) { + \fclose($client); + } + \fclose($server); + } + } + + public function testLoggerFallsBackToDefaultPort(): void + { + \putenv('TRAP_MONOLOG_PORT=invalid'); + + $logger = TrapHandle::logger(); + + self::assertInstanceOf(TrapLogger::class, $logger); + self::assertSame(9913, $this->getLoggerPort($logger)); + + $this->resetTrapLogger(); + \putenv('TRAP_MONOLOG_PORT=70000'); + + $logger = TrapHandle::logger(); + + self::assertInstanceOf(TrapLogger::class, $logger); + self::assertSame(9913, $this->getLoggerPort($logger)); + } + + public function testFallbackLineIsTextFormatted(): void + { + $logger = new TrapLogger(host: '127.0.0.1', port: 1); + $reflection = new \ReflectionClass($logger); + + $createRecord = $reflection->getMethod('createRecord'); + $formatFallbackLine = $reflection->getMethod('formatFallbackLine'); + + $record = $createRecord->invoke( + $logger, + 'error', + 'Something went wrong: {error}', + ['error' => 'boom'], + ); + + $line = $formatFallbackLine->invoke($logger, $record); + + self::assertStringStartsWith('[', $line); + self::assertStringContainsString('trap.ERROR: Something went wrong: boom', $line); + self::assertStringContainsString('{"error":"boom"}', $line); + } + + public function testInvalidLevelThrowsException(): void + { + $logger = new TrapLogger(host: '127.0.0.1', port: 1); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid log level "invalid".'); + + $logger->log('invalid', 'Bad level'); + } + + protected function setUp(): void + { + parent::setUp(); + $this->resetTrapLogger(); + \putenv('TRAP_MONOLOG_PORT'); + } + + protected function tearDown(): void + { + $this->resetTrapLogger(); + \putenv('TRAP_MONOLOG_PORT'); + parent::tearDown(); + } + + private function resetTrapLogger(): void + { + $reflection = new \ReflectionClass(TrapHandle::class); + $logger = $reflection->getProperty('logger'); + $logger->setValue(null, null); + } + + private function getLoggerPort(TrapLogger $logger): int + { + $reflection = new \ReflectionClass($logger); + $port = $reflection->getProperty('port'); + + return $port->getValue($logger); + } +}