diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fe9fbf1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + tests: + name: PHP ${{ matrix.php }} - ${{ matrix.dependencies }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.0', '8.1', '8.2', '8.3', '8.4'] + dependencies: ['highest'] + include: + - php: '8.0' + dependencies: 'lowest' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo, pdo_sqlite + coverage: xdebug + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-${{ matrix.dependencies }}- + + - name: Install dependencies (highest) + if: matrix.dependencies == 'highest' + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Install dependencies (lowest) + if: matrix.dependencies == 'lowest' + run: composer update --no-interaction --no-progress --prefer-dist --prefer-lowest + + - name: Run PHPUnit + run: vendor/bin/phpunit --colors=always + + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: pdo, pdo_sqlite + tools: composer:v2 + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress + + coding-standards: + name: Coding Standards + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + tools: composer:v2 + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: PHP-CS-Fixer (dry-run) + run: vendor/bin/php-cs-fixer fix --dry-run --diff --using-cache=no diff --git a/.gitignore b/.gitignore index a879886..d33e926 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /.vs/ /.vscode/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/.phpunit.result.cache +/.phpunit.cache/ +/.php-cs-fixer.cache +/build/ +/coverage/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..b34cea3 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'declare_strict_types' => true, + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'single_quote' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'native_function_invocation' => false, + 'native_constant_invocation' => false, + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], + ]) + ->setFinder($finder); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d6dbe3e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,81 @@ +# Changelog + +All notable changes to `initphp/logger` are documented here. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Full PSR-3 v3 compliance: every handler now implements + `Psr\Log\LoggerInterface` directly, including `Logger`, which previously + relied on `__call()` for dispatch. +- `Throwable` rendering for context placeholders — any `\Throwable` value in + the context array is rendered as `Class(code): message in file:line`. +- `string|\Stringable` accepted as message type across all handlers. +- `FileLogger`: + - Automatic creation of the parent directory (mode `0775`). + - Concurrent-safe writes via `FILE_APPEND | LOCK_EX`. + - `getPath()` accessor returning the token-interpolated path. +- `PDOLogger`: + - Table-name validation against `/^[A-Za-z_][A-Za-z0-9_]*$/` to prevent + identifier injection. + - Distinct, specific error messages for each invalid construction path. + - `getTable()` accessor. +- `Logger`: + - At least one logger is now required at construction time. + - Variadic `LoggerInterface` typing — non-`LoggerInterface` arguments raise + `TypeError` instead of being silently dropped. + - `getLoggers()` accessor. +- Comprehensive PHPUnit test suite (45 tests, 80 assertions). +- PHPStan (`level: max`) and PHP-CS-Fixer (PSR-12) configured and CI-enforced. +- GitHub Actions CI matrix: PHP 8.0 / 8.1 / 8.2 / 8.3 / 8.4. +- `docs/` directory with eight topic guides (getting started, file logger, + PDO logger, multi-logger, custom handlers, PSR-3 context, recipes, testing). + +### Changed + +- **Minimum PHP version raised to 8.0** (was 5.6). PHP 8.0+ is required for + PSR-3 v3 anyway. +- **`psr/log` constraint bumped to `^3.0`** (was the pinned `1.1.4`). +- All source files now declare `strict_types=1` and use property/parameter/ + return-type declarations. +- `Logger` is now `final`. Sub-classing it was never documented; users who + need to customise fan-out behaviour should compose with `LoggerInterface`. +- Log lines emitted by `FileLogger` now terminate with `PHP_EOL` instead of + beginning with one — the previous behaviour produced a stray empty line at + the top of every log file and made tailing awkward. + +### Fixed + +- `HelperTrait::$levels` no longer contains `LogLevel::WARNING` twice; the + list now mirrors the canonical eight PSR-3 levels in severity order. +- `FileLogger` no longer silently swallows write failures with the `@` + operator; failures are reported through `error_log()`. +- `FileLogger` rejects missing/empty/non-string `path` options at + construction with a clear `InvalidArgumentException`, instead of failing + obliquely at the first write. +- `PDOLogger` raises specific, actionable `InvalidArgumentException` messages + for each missing or wrongly-typed option (previously a single generic + "It must be a PDO object." message was thrown for several distinct + failures). +- `PDOLogger` uses `Psr\Log\InvalidArgumentException` consistently per + PSR-3 §1.1 (the previous code imported it but threw the SPL variant). +- `PDOLogger::__destruct` removed — manually nulling an injected PDO is not + the logger's responsibility and could mislead readers about ownership. + +### Removed + +- `Logger::__call()` magic dispatch. The class now implements + `LoggerInterface` properly. + + **Behaviour change for callers:** Previously, calling an unknown method on + `Logger` (e.g. `$logger->banana()`) silently no-op'd via `__call()`. + Such calls now raise `Error: Call to undefined method + InitPHP\Logger\Logger::banana()` at the PHP level. Legitimate PSR-3 calls + (`emergency()` … `debug()`, `log()`) are unaffected. + +## 1.0 - 2022-03-11 + +Initial release. diff --git a/README.md b/README.md index 3b32714..a5234c3 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,195 @@ # InitPHP Logger -Logger class in accordance with PSR-3 standards +A small, focused, PSR-3 compliant logger for PHP 8.0+. Ships with two built-in +handlers and a tiny multiplexer that fans every log record out to several +handlers at once. -[![Latest Stable Version](http://poser.pugx.org/initphp/logger/v)](https://packagist.org/packages/initphp/logger) [![Total Downloads](http://poser.pugx.org/initphp/logger/downloads)](https://packagist.org/packages/initphp/logger) [![Latest Unstable Version](http://poser.pugx.org/initphp/logger/v/unstable)](https://packagist.org/packages/initphp/logger) [![License](http://poser.pugx.org/initphp/logger/license)](https://packagist.org/packages/initphp/logger) [![PHP Version Require](http://poser.pugx.org/initphp/logger/require/php)](https://packagist.org/packages/initphp/logger) +[![Latest Stable Version](https://poser.pugx.org/initphp/logger/v)](https://packagist.org/packages/initphp/logger) +[![Total Downloads](https://poser.pugx.org/initphp/logger/downloads)](https://packagist.org/packages/initphp/logger) +[![License](https://poser.pugx.org/initphp/logger/license)](https://packagist.org/packages/initphp/logger) +[![PHP Version Require](https://poser.pugx.org/initphp/logger/require/php)](https://packagist.org/packages/initphp/logger) -## Features +## At a glance -- Keeping logs to the database with PDO. -- Printing log records to a file. -- Logging feature with multiple drivers. +- **`FileLogger`** — appends each record as a single line to a file, with + optional date-token placeholders in the path (`{year}/{month}/{day}/...`). +- **`PDOLogger`** — inserts each record as a row in a relational table, using + prepared statements. Works with any PDO driver (MySQL, PostgreSQL, SQLite…). +- **`Logger`** — accepts any number of `Psr\Log\LoggerInterface` instances and + forwards every call to all of them, in registration order. + +Everything implements `Psr\Log\LoggerInterface` (PSR-3 v3), so you can drop the +package into any framework or library that consumes that contract — or compose +it with handlers from other PSR-3 packages. ## Requirements -- PHP 5.6 or higher -- [PSR-3 Interface Package](https://www.php-fig.org/psr/psr-3/) -- PDO Extension (Only `PDOLogger`) +| Requirement | Version | +| --- | --- | +| PHP | `>= 8.0` | +| [`psr/log`](https://packagist.org/packages/psr/log) | `^3.0` | +| `ext-pdo` | Required only for `PDOLogger` | ## Installation -``` +```bash composer require initphp/logger ``` -## Using +## Quick start -### FileLogger +```php +require __DIR__ . '/vendor/autoload.php'; -```php -require_once "vendor/autoload.php"; -use \InitPHP\Logger\Logger; -use \InitPHP\Logger\FileLogger; +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; -$logFile = __DIR__ . '/logfile.log'; +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']) +); -$logger = new Logger(new FileLogger(['path' => $logFile])); +$logger->info('Service booted in {ms}ms', ['ms' => 42]); +$logger->error('Payment {id} failed', ['id' => 9182]); ``` -### PdoLogger +Produces, for example: -```php -require_once "vendor/autoload.php"; -use \InitPHP\Logger\Logger; -use \InitPHP\Logger\PDOLogger; - -$table = 'logs'; -$pdo = new \PDO('mysql:dbname=project;host=localhost', 'root', ''); - -$logger = new Logger(new PDOLogger(['pdo' => $pdo, 'table' => $table])); - -$logger->error('User {user} caused an error.', array('user' => 'muhametsafak')); -// INSERT INTO logs (level, message, date) VALUES ('ERROR', 'User muhametsafak caused an error.', '2022-03-11 13:05:45') ``` - -You can use the following SQL statement to create a sample MySQL table. - -```sql -CREATE TABLE `logs` ( - `level` ENUM('EMERGENCY','ALERT','CRITICAL','ERROR','WARNING','NOTICE','INFO','DEBUG') NOT NULL, - `message` TEXT NOT NULL, - `date` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -) ENGINE = InnoDB CHARSET=utf8mb4 COLLATE utf8mb4_general_ci; +2026-05-24T14:08:22+03:00 [INFO] Service booted in 42ms +2026-05-24T14:08:22+03:00 [ERROR] Payment 9182 failed ``` -### Multi Logger - -```php -require_once "vendor/autoload.php"; -use \InitPHP\Logger\Logger; -use \InitPHP\Logger\PDOLogger; -use \InitPHP\Logger\FileLogger; +## Handlers -$logFile = __DIR__ . '/logfile.log'; +### FileLogger -$table = 'logs'; -$pdo = new \PDO('mysql:dbname=project;host=localhost', 'root', ''); +```php +use InitPHP\Logger\FileLogger; -$logger = new Logger(new FileLogger(['path' => $logFile]), new PDOLogger(['pdo' => $pdo, 'table' => $table])); +$logger = new FileLogger([ + 'path' => __DIR__ . '/logs/app-{year}-{month}-{day}.log', +]); ``` -## Methods - -```php -public function emergency(string $msg, array $context = array()): void; +Path tokens (`{year}`, `{month}`, `{day}`, `{hour}`, `{minute}`, `{second}`) are +resolved once, at construction time, against the process clock. The parent +directory is created automatically (mode `0775`) if it does not already exist. +Writes use `FILE_APPEND | LOCK_EX`, so concurrent processes do not interleave +bytes within a single record. -public function alert(string $msg, array $context = array()): void; +Full reference: [`docs/02-file-logger.md`](docs/02-file-logger.md). -public function critical(string $msg, array $context = array()): void; +### PDOLogger -public function error(string $msg, array $context = array()): void; +```php +use InitPHP\Logger\PDOLogger; -public function warning(string $msg, array $context = array()): void; +$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'app', 'secret'); +$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); -public function notice(string $msg, array $context = array()): void; +$logger = new PDOLogger(['pdo' => $pdo, 'table' => 'logs']); -public function info(string $msg, array $context = array()): void; +$logger->error('User {user} caused an error.', ['user' => 'muhammetsafak']); +// INSERT INTO logs (level, message, date) VALUES ('ERROR', 'User muhammetsafak caused an error.', '2026-05-24 14:08:22') +``` -public function debug(string $msg, array $context = array()): void; +Reference DDL for MySQL: -public function log(string $level, string $msg, array $context = array()): void; +```sql +CREATE TABLE `logs` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `level` ENUM('EMERGENCY','ALERT','CRITICAL','ERROR','WARNING','NOTICE','INFO','DEBUG') NOT NULL, + `message` TEXT NOT NULL, + `date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_logs_level_date` (`level`, `date`) +) ENGINE = InnoDB CHARSET = utf8mb4 COLLATE utf8mb4_general_ci; ``` -All of the above methods are used the same way, except for the `log()` method. You can use the `log()` method for your own custom error levels. +PostgreSQL and SQLite variants are documented in +[`docs/03-pdo-logger.md`](docs/03-pdo-logger.md). -**Example 1 :** +### Multi-handler logging ```php -$logger->emergency("Something went wrong"); -``` +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; +use InitPHP\Logger\PDOLogger; -It prints an output like this to the log file. +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']), + new PDOLogger(['pdo' => $pdo, 'table' => 'logs']) +); +$logger->warning('cache miss for key {key}', ['key' => 'user:42']); +// Goes to BOTH the file and the database table. ``` -2021-09-29T13:34:47+02:00 [EMERGENCY] Something went wrong -``` -**Example 2:** +`Logger` accepts any `Psr\Log\LoggerInterface`, including handlers from other +PSR-3 packages — see [`docs/05-custom-handlers.md`](docs/05-custom-handlers.md). + +## PSR-3 surface + +Every handler exposes the full PSR-3 API: ```php -$logger->error("User {username} caused an error.", ["username" => "john"]); +$logger->emergency(string|\Stringable $message, array $context = []): void; +$logger->alert (string|\Stringable $message, array $context = []): void; +$logger->critical (string|\Stringable $message, array $context = []): void; +$logger->error (string|\Stringable $message, array $context = []): void; +$logger->warning (string|\Stringable $message, array $context = []): void; +$logger->notice (string|\Stringable $message, array $context = []): void; +$logger->info (string|\Stringable $message, array $context = []): void; +$logger->debug (string|\Stringable $message, array $context = []): void; +$logger->log ($level, string|\Stringable $message, array $context = []): void; ``` -It prints an output like this to the log file. +Context placeholders (`{name}`) are expanded with the matching key from +`$context`. Booleans render as `true` / `false`, `null` renders as the empty +string, `\Throwable` values render as `Class(code): message in file:line`, +and anything implementing `__toString()` is cast to string. Arrays and +non-stringable objects are skipped — see +[`docs/06-psr3-context.md`](docs/06-psr3-context.md). -``` -2021-09-29T13:34:47+02:00 [ERROR] User john caused an error. -``` +Unknown log levels throw `Psr\Log\InvalidArgumentException`, per PSR-3 §1.1. + +## Documentation -That is all. +Topic-by-topic guides live in [`docs/`](docs/): -*** +- [`01-getting-started.md`](docs/01-getting-started.md) +- [`02-file-logger.md`](docs/02-file-logger.md) +- [`03-pdo-logger.md`](docs/03-pdo-logger.md) +- [`04-multi-logger.md`](docs/04-multi-logger.md) +- [`05-custom-handlers.md`](docs/05-custom-handlers.md) +- [`06-psr3-context.md`](docs/06-psr3-context.md) +- [`07-recipes.md`](docs/07-recipes.md) +- [`08-testing-your-logging.md`](docs/08-testing-your-logging.md) + +## Testing the package itself + +```bash +composer install +composer test # PHPUnit +composer phpstan # PHPStan (level max) +composer cs-check # PHP-CS-Fixer dry-run +composer ci # all of the above +``` -## Getting Help +## Contributing -If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker. +Please read the org-wide +[`CONTRIBUTING.md`](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +before opening a pull request. Bug reports and feature ideas go through +[Issues](https://github.com/InitPHP/Logger/issues) and +[Discussions](https://github.com/orgs/InitPHP/discussions). -## Credits +## Security -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) +If you discover a security vulnerability, please follow the instructions in the +org-wide +[`SECURITY.md`](https://github.com/InitPHP/.github/blob/main/SECURITY.md). Do +**not** open a public issue. ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](LICENSE). Copyright © InitPHP. diff --git a/composer.json b/composer.json index 8b4efae..bbd4cf3 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,9 @@ { "name": "initphp/logger", - "description": "PSR-3 Logger Library", + "description": "PSR-3 compliant logger with File and PDO handlers, plus a multiplexer for sending each log record to several handlers at once.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\Logger\\": "src/" - } - }, + "keywords": ["logger", "log", "psr-3", "psr3", "logging", "file-logger", "pdo-logger"], "authors": [ { "name": "Muhammet ŞAFAK", @@ -16,9 +12,42 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", "require": { - "php": ">=5.6", - "psr/log": "1.1.4" - } + "php": ">=8.0", + "psr/log": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6", + "phpstan/phpstan": "^1.11", + "friendsofphp/php-cs-fixer": "^3.50" + }, + "autoload": { + "psr-4": { + "InitPHP\\Logger\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\Logger\\Tests\\": "tests/" + } + }, + "provide": { + "psr/log-implementation": "3.0" + }, + "scripts": { + "test": "phpunit", + "phpstan": "phpstan analyse", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "ci": [ + "@cs-check", + "@phpstan", + "@test" + ] + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..396b459 --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,94 @@ +# Getting Started + +This guide walks you from `composer install` to a first working log line in +under five minutes. + +## 1. Install + +```bash +composer require initphp/logger +``` + +The package requires PHP 8.0 or newer and pulls `psr/log:^3.0` in. No other +runtime dependencies are mandatory; `ext-pdo` is only needed if you intend to +use `PDOLogger`. + +## 2. Pick a handler + +| Handler | Storage | When to use | +| --- | --- | --- | +| `InitPHP\Logger\FileLogger` | Local filesystem | Default. Quick to set up, easy to tail with `tail -f`, plays well with `logrotate`. | +| `InitPHP\Logger\PDOLogger` | SQL database | When logs need to be queryable by your operations team or by application code. | +| `InitPHP\Logger\Logger` | (multiplexer) | When you want both — or any other combination of PSR-3 handlers. | + +You can also bring your own handler — see +[`05-custom-handlers.md`](05-custom-handlers.md). + +## 3. Write the first log line + +```php + __DIR__ . '/logs/app.log', +]); + +$logger->info('Hello from InitPHP Logger'); +$logger->error('User {user} hit a {kind} error', [ + 'user' => 'jane', + 'kind' => 'transient', +]); +``` + +Run the script and inspect the file: + +``` +2026-05-24T14:08:22+03:00 [INFO] Hello from InitPHP Logger +2026-05-24T14:08:22+03:00 [ERROR] User jane hit a transient error +``` + +Notes: + +- The `logs/` directory is created automatically; you do not need to `mkdir` it. +- Every log line ends with `PHP_EOL`. Concurrent writers cannot interleave the + bytes of a single record. +- Failure to write does **not** throw — PSR-3 method calls must remain + side-effect-free for application flow. A `FileLogger` write failure is + surfaced through `error_log()`. + +## 4. Type-hint the contract, not the handler + +The whole point of PSR-3 is that consumers depend on the **interface**, not on +a specific package. So when wiring loggers through your application: + +```php +use Psr\Log\LoggerInterface; + +final class PaymentService +{ + public function __construct(private LoggerInterface $logger) {} + + public function charge(int $userId): void + { + $this->logger->info('charging user {id}', ['id' => $userId]); + } +} +``` + +Anyone can pass an `InitPHP\Logger\FileLogger`, an `InitPHP\Logger\Logger`, +Monolog, the `NullLogger` in tests, or their own implementation. Your service +does not need to care. + +## 5. Where to go next + +- [Send the same record to several places](04-multi-logger.md) +- [Inspect every option of `FileLogger`](02-file-logger.md) +- [Set up a relational backend with `PDOLogger`](03-pdo-logger.md) +- [Understand context placeholders and `{exception}`](06-psr3-context.md) +- [Build your own handler](05-custom-handlers.md) +- [Cookbook of common setups](07-recipes.md) +- [Test code that uses this logger](08-testing-your-logging.md) diff --git a/docs/02-file-logger.md b/docs/02-file-logger.md new file mode 100644 index 0000000..3ca3eaf --- /dev/null +++ b/docs/02-file-logger.md @@ -0,0 +1,125 @@ +# `FileLogger` + +`InitPHP\Logger\FileLogger` appends each PSR-3 record as a single line to a +file on the local filesystem. + +## Options + +The constructor takes a single associative array. Only one key is recognised: + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `path` | `string` | yes | Target file path. May contain date tokens (see below). | + +A missing, non-string, or empty `path` raises `\InvalidArgumentException` +synchronously during construction — failures are surfaced *before* you ever try +to log. + +```php +use InitPHP\Logger\FileLogger; + +$logger = new FileLogger([ + 'path' => __DIR__ . '/logs/app.log', +]); +``` + +## Output format + +Each call to `log()` writes exactly one line: + +``` + [] \n +``` + +Example: + +``` +2026-05-24T14:08:22+03:00 [WARNING] cache miss for key user:42 +``` + +The timestamp comes from `DateTimeImmutable('now')` formatted as `c` +(ISO-8601 with offset), and reflects the PHP-wide default timezone — set it +explicitly with `date_default_timezone_set()` if you need a specific one. + +## Path tokens + +The `path` value is interpolated **once**, at construction time, against the +process clock. Tokens follow the same `{name}` syntax used for PSR-3 context +placeholders: + +| Token | Replacement | +| --- | --- | +| `{year}` | `date('Y')` (e.g. `2026`) | +| `{month}` | `date('m')` (e.g. `05`) | +| `{day}` | `date('d')` (e.g. `24`) | +| `{hour}` | `date('H')` (e.g. `14`) | +| `{minute}` | `date('i')` (e.g. `08`) | +| `{second}` | `date('s')` (e.g. `22`) | + +```php +$logger = new FileLogger([ + 'path' => __DIR__ . '/logs/app-{year}-{month}-{day}.log', +]); + +echo $logger->getPath(); +// /var/www/app/logs/app-2026-05-24.log +``` + +Because expansion happens **once**, a long-running PHP process started before +midnight will keep writing to the previous day's file. If you need true daily +rotation, see [`07-recipes.md`](07-recipes.md#daily-rotation). + +## Directory creation + +The parent directory of `path` is created automatically using +`mkdir($dir, 0775, true)` if it does not already exist. If creation fails — for +example because of restrictive filesystem permissions — the failure is reported +through `error_log()` and the subsequent write attempt proceeds normally +(producing its own `error_log()` notice on failure). + +## Concurrency + +Writes use `file_put_contents($path, $line, FILE_APPEND | LOCK_EX)`. The +`LOCK_EX` flag means concurrent PHP processes will not interleave the bytes of +a single line. It does **not** guarantee global ordering across processes; the +file's natural append-only semantics handle that. + +## Error handling + +PSR-3 §1.1 requires that log calls do not raise exceptions for ordinary +backend failures. `FileLogger` honours that rule: + +- A write that returns `false` is reported via `error_log()` but does not + throw. +- A directory creation failure is reported via `error_log()` but does not + throw. +- Unknown log levels (passed to `log()` directly) **do** throw + `Psr\Log\InvalidArgumentException`, as PSR-3 explicitly permits. + +## Inspecting the resolved path + +`getPath()` returns the final, token-interpolated path: + +```php +$logger = new FileLogger(['path' => '/var/log/app-{year}.log']); +$logger->getPath(); // "/var/log/app-2026.log" +``` + +This is convenient in tests and in administrative tooling. + +## Example: all eight PSR-3 levels + +```php +use InitPHP\Logger\FileLogger; + +$logger = new FileLogger(['path' => __DIR__ . '/logs/levels.log']); + +$logger->emergency('system unusable'); +$logger->alert ('action required immediately'); +$logger->critical ('critical condition'); +$logger->error ('runtime error, no immediate action required'); +$logger->warning ('exceptional condition, not an error'); +$logger->notice ('normal but significant event'); +$logger->info ('informational message'); +$logger->debug ('debug-level detail'); +``` diff --git a/docs/03-pdo-logger.md b/docs/03-pdo-logger.md new file mode 100644 index 0000000..b1c29b3 --- /dev/null +++ b/docs/03-pdo-logger.md @@ -0,0 +1,147 @@ +# `PDOLogger` + +`InitPHP\Logger\PDOLogger` writes each PSR-3 record as a row in a relational +database table, through a user-supplied `PDO` connection. + +## Options + +| Key | Type | Required | Description | +| --- | --- | --- | --- | +| `pdo` | `\PDO` | yes | An already-configured PDO connection. | +| `table` | `string` | yes | Destination table name. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`. | + +The constructor validates both keys and raises `\InvalidArgumentException` with +a specific message for each failure mode: + +```text +PDOLogger requires a "pdo" option. +PDOLogger "pdo" option must be a PDO instance. +PDOLogger requires a "table" option. +PDOLogger "table" option must be a non-empty string. +PDOLogger "table" option "" is not a valid SQL identifier; expected /^[A-Za-z_][A-Za-z0-9_]*$/. +``` + +## Why the table-name regex? + +Prepared statements parameterise **values**, not **identifiers**. To keep +`PDOLogger` injection-safe without depending on a per-driver quoting helper, +the constructor refuses any table name that is not a plain SQL identifier. + +If your schema needs a name outside that grammar (e.g. with hyphens), wrap the +logger and quote the identifier yourself — but be aware that the responsibility +for safe identifier handling then sits with your code. + +## What gets inserted + +For every call, exactly one row is inserted: + +```sql +INSERT INTO (level, message, date) VALUES (?, ?, ?) +``` + +| Column | Value | +| --- | --- | +| `level` | `strtoupper($level)` — e.g. `'ERROR'` | +| `message` | The interpolated message (placeholders already expanded) | +| `date` | `date('Y-m-d H:i:s')` at insertion time | + +`message` is bound through PDO, so SQL metacharacters in the payload are safe. + +## Reference DDL + +### MySQL / MariaDB + +```sql +CREATE TABLE `logs` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `level` ENUM('EMERGENCY','ALERT','CRITICAL','ERROR','WARNING','NOTICE','INFO','DEBUG') NOT NULL, + `message` TEXT NOT NULL, + `date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY `idx_logs_level_date` (`level`, `date`) +) ENGINE = InnoDB CHARSET = utf8mb4 COLLATE utf8mb4_general_ci; +``` + +### PostgreSQL + +```sql +CREATE TYPE log_level AS ENUM ( + 'EMERGENCY','ALERT','CRITICAL','ERROR','WARNING','NOTICE','INFO','DEBUG' +); + +CREATE TABLE logs ( + id BIGSERIAL PRIMARY KEY, + level log_level NOT NULL, + message TEXT NOT NULL, + date TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_logs_level_date ON logs(level, date); +``` + +### SQLite + +```sql +CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT NOT NULL, + date TEXT NOT NULL +); + +CREATE INDEX idx_logs_level_date ON logs(level, date); +``` + +You only need the `level`, `message` and `date` columns — extra columns are +ignored by the INSERT. Add an `id` (or other surrogate key), foreign keys, +hashed indexes etc. to suit your operational needs. + +## Recommended PDO configuration + +```php +$pdo = new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]); +``` + +`ERRMODE_EXCEPTION` ensures that PDO problems (lost connection, deadlock, etc.) +surface as `PDOException` rather than silently failing. PSR-3 §1.1 only +mandates that **the logger** must not raise for ordinary backend errors; the +underlying PDO infrastructure is free to. + +If you wrap `PDOLogger` in `InitPHP\Logger\Logger` together with a +`FileLogger`, a database outage will not prevent your file log from being +written — see [`04-multi-logger.md`](04-multi-logger.md#fault-isolation). + +## Example + +```php +use InitPHP\Logger\PDOLogger; + +$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', 'app', 'secret', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, +]); + +$logger = new PDOLogger([ + 'pdo' => $pdo, + 'table' => 'logs', +]); + +$logger->error('Order {id} could not be charged', ['id' => 9182]); +``` + +After this call the `logs` table contains: + +| level | message | date | +| --- | --- | --- | +| `ERROR` | `Order 9182 could not be charged` | `2026-05-24 14:08:22` | + +## Reading the table name back + +`getTable()` returns the validated, configured table name — useful for +diagnostics: + +```php +$logger->getTable(); // "logs" +``` diff --git a/docs/04-multi-logger.md b/docs/04-multi-logger.md new file mode 100644 index 0000000..9ea4477 --- /dev/null +++ b/docs/04-multi-logger.md @@ -0,0 +1,127 @@ +# `Logger` (multi-handler multiplexer) + +`InitPHP\Logger\Logger` is a tiny **fan-out multiplexer**: it accepts one or +more `Psr\Log\LoggerInterface` instances and forwards every PSR-3 call to all +of them, in the order they were supplied. + +It is the only public class in the package that you typically pass around +through your application — the actual handlers (`FileLogger`, `PDOLogger`, +custom handlers) plug into it. + +## Constructor + +```php +public function __construct(\Psr\Log\LoggerInterface ...$loggers); +``` + +- Variadic `LoggerInterface` — PHP itself enforces the type. Anything else + raises `TypeError` immediately. +- At least one logger is required. An empty argument list throws + `\InvalidArgumentException`. + +```php +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; +use InitPHP\Logger\PDOLogger; + +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']), + new PDOLogger(['pdo' => $pdo, 'table' => 'logs']) +); +``` + +## Dispatch semantics + +Every PSR-3 method (`emergency()`, …, `debug()`, and `log()`) forwards directly +to each inner logger: + +```php +$logger->error('boom', ['k' => 'v']); +// is equivalent to, for handlers [a, b, c]: +$a->log('error', 'boom', ['k' => 'v']); +$b->log('error', 'boom', ['k' => 'v']); +$c->log('error', 'boom', ['k' => 'v']); +``` + +The order is the registration order, and it is stable. + +## Exception propagation + +`Logger` does **not** catch exceptions thrown by inner handlers: + +- PSR-3 §1.1 explicitly permits `Psr\Log\InvalidArgumentException` for unknown + levels, and you almost always want to see those. +- Other exceptions (e.g. `PDOException` from a database outage) propagate out + of `Logger::log()` and abort the rest of the fan-out. + +If your handlers can fail independently and you want fault isolation — keep +writing to the file even when the database is down — wrap the fragile handler: + +```php +final class TolerantLogger implements \Psr\Log\LoggerInterface +{ + use \Psr\Log\LoggerTrait; + + public function __construct(private \Psr\Log\LoggerInterface $inner) {} + + public function log($level, string|\Stringable $message, array $context = []): void + { + try { + $this->inner->log($level, $message, $context); + } catch (\Throwable $e) { + error_log('logger handler failed: ' . $e->getMessage()); + } + } +} + +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']), + new TolerantLogger(new PDOLogger(['pdo' => $pdo, 'table' => 'logs'])) +); +``` + +## Fault isolation + +A common production pattern is "always write to the file; best-effort to the +database": + +1. Put `FileLogger` **first** so it always runs. +2. Wrap `PDOLogger` in your own `TolerantLogger`-style decorator so its + failures do not abort the chain. + +Because `Logger` is iterative, the order matters: if the second handler in the +chain throws and is not wrapped, the third handler will not be reached. + +## Introspecting the chain + +`getLoggers()` returns the registered handlers in order, which is useful for +diagnostics and for tests: + +```php +$logger->getLoggers(); // [FileLogger, PDOLogger] +``` + +The returned array is a copy of the internal list; modifying it does not +affect the logger. + +## Composing with other PSR-3 packages + +`Logger` does not care where its handlers come from. You can mix InitPHP +handlers with third-party ones, e.g. Monolog: + +```php +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; +use Monolog\Logger as Monolog; +use Monolog\Handler\StreamHandler; + +$monolog = new Monolog('app'); +$monolog->pushHandler(new StreamHandler('php://stderr')); + +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']), + $monolog +); +``` + +The only contract is `Psr\Log\LoggerInterface`. diff --git a/docs/05-custom-handlers.md b/docs/05-custom-handlers.md new file mode 100644 index 0000000..cfa28bd --- /dev/null +++ b/docs/05-custom-handlers.md @@ -0,0 +1,124 @@ +# Writing a Custom Handler + +`InitPHP\Logger\Logger` accepts any `Psr\Log\LoggerInterface`, so adding new +backends (syslog, Slack, an in-memory ring buffer for tests, …) is simply a +matter of writing a class that implements that contract. + +The path of least resistance is to extend `Psr\Log\AbstractLogger`: it provides +default implementations of `emergency()` through `debug()` that delegate to a +single `log()` method, so you only implement `log()`. + +## Minimal example: a syslog handler + +```php +use Psr\Log\AbstractLogger; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LogLevel; + +final class SyslogHandler extends AbstractLogger +{ + private const PRIORITY = [ + LogLevel::EMERGENCY => LOG_EMERG, + LogLevel::ALERT => LOG_ALERT, + LogLevel::CRITICAL => LOG_CRIT, + LogLevel::ERROR => LOG_ERR, + LogLevel::WARNING => LOG_WARNING, + LogLevel::NOTICE => LOG_NOTICE, + LogLevel::INFO => LOG_INFO, + LogLevel::DEBUG => LOG_DEBUG, + ]; + + public function __construct(string $ident = 'app', int $facility = LOG_USER) + { + openlog($ident, LOG_PID | LOG_ODELAY, $facility); + } + + public function log($level, string|\Stringable $message, array $context = []): void + { + $normalised = strtolower((string) $level); + + if (!isset(self::PRIORITY[$normalised])) { + throw new InvalidArgumentException(sprintf('Unknown log level "%s".', $level)); + } + + syslog(self::PRIORITY[$normalised], $this->interpolate((string) $message, $context)); + } + + private function interpolate(string $message, array $context): string + { + if ($context === []) { + return $message; + } + $replace = []; + foreach ($context as $key => $value) { + if (is_scalar($value) || $value instanceof \Stringable) { + $replace['{' . $key . '}'] = (string) $value; + } + } + return strtr($message, $replace); + } +} +``` + +Plug it into the multiplexer alongside the built-in handlers: + +```php +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; + +$logger = new Logger( + new FileLogger(['path' => __DIR__ . '/logs/app.log']), + new SyslogHandler('myapp', LOG_USER) +); +``` + +## Sharing the placeholder logic + +If your handler needs the same interpolation rules as the built-in handlers +(null/bool/Throwable handling, identical `{key}` syntax), you can `use` the +internal `HelperTrait`. It is marked `@internal`, so prefer composition with +your own helper unless you are happy to track the package's behaviour. + +```php +use InitPHP\Logger\HelperTrait; +use Psr\Log\AbstractLogger; + +final class ArrayLogger extends AbstractLogger +{ + use HelperTrait; + + /** @var list, date: string}> */ + public array $records = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->logLevelVerify($level); + + $this->records[] = [ + 'level' => strtoupper((string) $level), + 'message' => $this->interpolate($message, $context), + 'context' => $context, + 'date' => $this->getDate('c'), + ]; + } +} +``` + +`ArrayLogger` is particularly handy in tests — see +[`08-testing-your-logging.md`](08-testing-your-logging.md). + +## Guidelines + +A few rules to keep your handler well-behaved as a PSR-3 citizen: + +- **Implement `LoggerInterface`** (directly, or by extending `AbstractLogger`). + Anything else won't fit into `InitPHP\Logger\Logger`. +- **Do not throw for ordinary backend failures.** Use `error_log()` or a + fallback handler. The only case PSR-3 condones throwing is + `Psr\Log\InvalidArgumentException` for unknown levels. +- **Render `\Throwable` context values.** Either via the package's + `HelperTrait`, or by special-casing `instanceof \Throwable` yourself. +- **Accept `string|\Stringable` messages.** PSR-3 v3 requires it; PHP's type + system will enforce it for you if you copy the `log()` signature exactly. +- **Make construction validate eagerly.** Bad config should fail at boot, not + on the first log call. diff --git a/docs/06-psr3-context.md b/docs/06-psr3-context.md new file mode 100644 index 0000000..46582f2 --- /dev/null +++ b/docs/06-psr3-context.md @@ -0,0 +1,119 @@ +# Context Placeholders and `{exception}` + +PSR-3 §1.2 specifies a uniform placeholder syntax for log messages: +**`{name}`**, where `name` is the key of an entry in the `$context` array. +This page documents how the built-in handlers expand each kind of value. + +## Placeholder syntax + +```php +$logger->info('User {user} hit a {kind} error', [ + 'user' => 'jane', + 'kind' => 'transient', +]); +// → "User jane hit a transient error" +``` + +- Keys must be strings. Integer keys are ignored (they cannot appear as + placeholders in the message anyway). +- Curly braces inside the message that do not correspond to a context key are + left **untouched**. This is intentional — it means you can include literal + `{` and `}` in your messages without escaping, as long as they do not happen + to spell a key you also passed. + +## How context values are rendered + +| Value type | Rendered as | +| --- | --- | +| `null` | empty string `""` | +| `true` | `"true"` | +| `false` | `"false"` | +| `int`, `float` | `(string) $value` | +| `string` | the value verbatim | +| `\Stringable` (incl. classes with `__toString()`) | `(string) $value` | +| `\Throwable` | `"(): in :"` | +| arrays | placeholder **left untouched** | +| non-stringable objects | placeholder **left untouched** | + +Examples: + +```php +$logger->info('a={a} b={b} c={c} d={d}', [ + 'a' => null, + 'b' => true, + 'c' => false, + 'd' => 3.14, +]); +// → "a= b=true c=false d=3.14" +``` + +```php +try { + throw new RuntimeException('disk full', 42); +} catch (RuntimeException $e) { + $logger->error('write failed: {exception}', ['exception' => $e]); +} +// → "write failed: RuntimeException(42): disk full in /app/src/Writer.php:88" +``` + +Arrays and arbitrary objects are deliberately skipped rather than coerced to +`"Array"` or `"Object"`. If you want to log structured data, format it +yourself: + +```php +$logger->info('payload: {body}', ['body' => json_encode($data)]); +``` + +## The `{exception}` convention + +PSR-3 §1.3 says: + +> Implementors MUST still ensure that the `exception` key is not interfered +> with should they get one from the user. + +The built-in handlers honour this. The `exception` key is **not** treated as +magic at the API surface: it follows the same `{exception}` placeholder rule as +every other key. The only special-casing is that **any** `\Throwable` in the +context — under any key — renders as +`"(): in :"`. + +If you also want the stack trace, format it yourself: + +```php +$logger->error( + "write failed: {exception}\n{trace}", + [ + 'exception' => $e, + 'trace' => $e->getTraceAsString(), + ] +); +``` + +## Stringable messages + +The message itself may be a `\Stringable` rather than a plain string: + +```php +final class FormattedMessage implements \Stringable +{ + public function __construct(private string $template, private array $args) {} + + public function __toString(): string + { + return vsprintf($this->template, $this->args); + } +} + +$logger->info(new FormattedMessage('took %d ms (%s)', [42, 'cache hit'])); +// → "took 42 ms (cache hit)" +``` + +`{}` placeholders work on the **rendered** string, so combining `Stringable` +messages with context placeholders is fully supported: + +```php +$logger->warning( + new FormattedMessage('user %d (%s)', [7, 'jane']), + ['extra' => 'context unused — no placeholder'] +); +``` diff --git a/docs/07-recipes.md b/docs/07-recipes.md new file mode 100644 index 0000000..dfac87f --- /dev/null +++ b/docs/07-recipes.md @@ -0,0 +1,210 @@ +# Recipes + +A collection of small, copy-pasteable patterns for common production needs. + +## Daily rotation + +The `path` tokens in `FileLogger` are resolved **once**, at construction time. +For true daily rotation in a long-running process, rebuild the logger on each +day boundary — or, more commonly, let an external tool such as `logrotate` +handle rotation while you keep the path static. + +### Per-request daily files (PHP-FPM, CLI scripts) + +If your PHP process is short-lived (the typical web request, or a one-shot +CLI command), tokens give you free daily rotation: + +```php +$logger = new FileLogger([ + 'path' => '/var/log/app/{year}/{month}/{day}.log', +]); +``` + +The directory is created automatically on the first write of each day. + +### Long-running workers + +For workers (queue consumers, daemons), prefer external rotation: + +``` +/etc/logrotate.d/myapp +--------------------- +/var/log/app/app.log { + daily + rotate 14 + compress + delaycompress + missingok + notifempty + copytruncate +} +``` + +`copytruncate` lets `logrotate` rotate the file without signalling the PHP +process, which keeps things simple. + +## Logging only above a threshold + +PSR-3 itself does not include a level filter, but composition is enough. + +```php +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +final class MinLevelLogger extends AbstractLogger +{ + private const SEVERITY = [ + LogLevel::EMERGENCY => 0, + LogLevel::ALERT => 1, + LogLevel::CRITICAL => 2, + LogLevel::ERROR => 3, + LogLevel::WARNING => 4, + LogLevel::NOTICE => 5, + LogLevel::INFO => 6, + LogLevel::DEBUG => 7, + ]; + + public function __construct( + private LoggerInterface $inner, + private string $minLevel = LogLevel::WARNING, + ) {} + + public function log($level, string|\Stringable $message, array $context = []): void + { + $current = self::SEVERITY[strtolower((string) $level)] ?? null; + $min = self::SEVERITY[strtolower($this->minLevel)] ?? null; + if ($current === null || $min === null || $current > $min) { + return; + } + $this->inner->log($level, $message, $context); + } +} +``` + +Now wire it into the multiplexer: + +```php +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; + +$logger = new Logger( + new MinLevelLogger( + new FileLogger(['path' => '/var/log/app/audit.log']), + LogLevel::ERROR + ) +); +``` + +## Two files: one for everything, one for errors only + +```php +use InitPHP\Logger\FileLogger; +use InitPHP\Logger\Logger; +use Psr\Log\LogLevel; + +$logger = new Logger( + new FileLogger(['path' => '/var/log/app/app.log']), + new MinLevelLogger( + new FileLogger(['path' => '/var/log/app/errors.log']), + LogLevel::ERROR + ) +); +``` + +## Database logger that survives outages + +By itself, `PDOLogger::log()` lets `PDOException` propagate — a database +outage will abort your application's log call. Wrap it to swallow failures and +fall back to `error_log()`: + +```php +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; + +final class TolerantLogger extends AbstractLogger +{ + public function __construct(private LoggerInterface $inner) {} + + public function log($level, string|\Stringable $message, array $context = []): void + { + try { + $this->inner->log($level, $message, $context); + } catch (\Throwable $e) { + error_log(sprintf( + 'TolerantLogger: %s (level=%s, msg=%s)', + $e->getMessage(), + (string) $level, + (string) $message + )); + } + } +} +``` + +Then: + +```php +$logger = new Logger( + new FileLogger(['path' => '/var/log/app/app.log']), + new TolerantLogger(new PDOLogger(['pdo' => $pdo, 'table' => 'logs'])) +); +``` + +The file write is unaffected by database problems. + +## Framework integration + +### Symfony / Laravel service container + +Both frameworks already accept `Psr\Log\LoggerInterface` as a type hint in +their service definitions. Register `InitPHP\Logger\Logger` as the service: + +```yaml +# Symfony +services: + Psr\Log\LoggerInterface: + class: InitPHP\Logger\Logger + arguments: + - '@app.logger.file' + - '@app.logger.pdo' + + app.logger.file: + class: InitPHP\Logger\FileLogger + arguments: + - { path: '%kernel.logs_dir%/app.log' } + + app.logger.pdo: + class: InitPHP\Logger\PDOLogger + arguments: + - { pdo: '@PDO', table: 'logs' } +``` + +```php +// Laravel +$this->app->singleton(\Psr\Log\LoggerInterface::class, function ($app) { + return new \InitPHP\Logger\Logger( + new \InitPHP\Logger\FileLogger(['path' => storage_path('logs/app.log')]), + new \InitPHP\Logger\PDOLogger([ + 'pdo' => \DB::connection()->getPdo(), + 'table' => 'logs', + ]), + ); +}); +``` + +Application code simply injects `LoggerInterface`. + +## Timezone control + +`FileLogger` and `PDOLogger` produce timestamps with `DateTimeImmutable('now')`, +which respects the PHP-wide timezone. Set it explicitly at boot: + +```php +date_default_timezone_set('Europe/Istanbul'); +``` + +If different handlers need different timezones, wrap each one in a small +adapter that converts the timestamp post hoc — or render timestamps into the +message and let your storage tier (PostgreSQL, Elasticsearch…) do the +timezone conversion. diff --git a/docs/08-testing-your-logging.md b/docs/08-testing-your-logging.md new file mode 100644 index 0000000..60d0535 --- /dev/null +++ b/docs/08-testing-your-logging.md @@ -0,0 +1,166 @@ +# Testing Code That Uses the Logger + +If your services accept `Psr\Log\LoggerInterface` (the recommended pattern), +testing the logging behaviour is mostly a matter of plugging in a different +implementation. + +## Use `NullLogger` when you do not care + +For tests that exercise business logic but do not assert on log output, the +standard `Psr\Log\NullLogger` is plenty: + +```php +use PHPUnit\Framework\TestCase; +use Psr\Log\NullLogger; + +final class PaymentServiceTest extends TestCase +{ + public function test_it_charges_the_user(): void + { + $service = new PaymentService(new NullLogger()); + + $service->charge(7, 100); + + $this->assertSame(100, $service->totalCharged()); + } +} +``` + +`NullLogger` ships with `psr/log` itself; no extra dependency needed. + +## Use an in-memory `ArrayLogger` when you do + +When the assertion is "we logged the right thing", capture the records: + +```php +use InitPHP\Logger\HelperTrait; +use Psr\Log\AbstractLogger; + +final class ArrayLogger extends AbstractLogger +{ + use HelperTrait; + + /** + * @var list, date: string}> + */ + public array $records = []; + + public function log($level, string|\Stringable $message, array $context = []): void + { + $this->logLevelVerify($level); + + $this->records[] = [ + 'level' => strtoupper((string) $level), + 'message' => $this->interpolate($message, $context), + 'context' => $context, + 'date' => $this->getDate('c'), + ]; + } +} +``` + +```php +public function test_it_logs_a_warning_when_balance_is_low(): void +{ + $logger = new ArrayLogger(); + $service = new PaymentService($logger); + + $service->charge(7, 100); + + $this->assertCount(1, $logger->records); + $this->assertSame('WARNING', $logger->records[0]['level']); + $this->assertSame('user 7 balance is below threshold', $logger->records[0]['message']); + $this->assertSame(['user_id' => 7], $logger->records[0]['context']); +} +``` + +## PHPUnit mocks when you only care about one call + +If you just want to assert that a specific call happened: + +```php +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; + +final class PaymentServiceTest extends TestCase +{ + public function test_it_logs_a_warning(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('log') + ->with(LogLevel::WARNING, $this->stringContains('balance is below threshold')); + + $service = new PaymentService($logger); + $service->charge(7, 100); + } +} +``` + +Note that mocking the `log()` method covers all calls — `warning('x')` is +internally `log(LogLevel::WARNING, 'x', [])` via `AbstractLogger`. + +## Testing `FileLogger` directly + +For integration tests that actually exercise file IO, write to a temporary +directory and clean up afterwards: + +```php +final class FileLoggerIntegrationTest extends TestCase +{ + private string $dir; + + protected function setUp(): void + { + $this->dir = sys_get_temp_dir() . '/my-app-tests-' . uniqid('', true); + mkdir($this->dir, 0775, true); + } + + protected function tearDown(): void + { + // recursive rmdir — see the package's own test suite for a helper + } + + public function test_writes_a_single_line(): void + { + $logger = new \InitPHP\Logger\FileLogger(['path' => $this->dir . '/app.log']); + + $logger->info('hi'); + + $this->assertStringContainsString('[INFO] hi', file_get_contents($this->dir . '/app.log')); + } +} +``` + +## Testing `PDOLogger` directly + +In-memory SQLite is ideal: + +```php +final class PDOLoggerIntegrationTest extends TestCase +{ + private PDO $pdo; + + protected function setUp(): void + { + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec('CREATE TABLE logs (level TEXT, message TEXT, date TEXT)'); + } + + public function test_inserts_one_row_per_log_call(): void + { + $logger = new \InitPHP\Logger\PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + $logger->error('boom'); + + $row = $this->pdo->query('SELECT * FROM logs')->fetch(PDO::FETCH_ASSOC); + $this->assertSame('ERROR', $row['level']); + $this->assertSame('boom', $row['message']); + } +} +``` + +You will find the same patterns, with more edge cases, in this package's own +[`tests/`](../tests) directory. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..bd1ec2f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src + - tests + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..df812b7 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,22 @@ + + + + + tests + + + + + src + + + diff --git a/src/FileLogger.php b/src/FileLogger.php index 719bdc3..3d15ad5 100644 --- a/src/FileLogger.php +++ b/src/FileLogger.php @@ -1,59 +1,123 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ -namespace InitPHP\Logger; +declare(strict_types=1); -use \Psr\Log\AbstractLogger; -use \Psr\Log\LoggerInterface; +namespace InitPHP\Logger; -use const PHP_EOL; -use const FILE_APPEND; +use InvalidArgumentException; +use Psr\Log\AbstractLogger; +use Stringable; -use function strtoupper; use function date; +use function dirname; +use function error_log; +use function file_put_contents; +use function is_dir; +use function is_string; +use function mkdir; +use function sprintf; +use function strtoupper; + +use const FILE_APPEND; +use const LOCK_EX; +use const PHP_EOL; -class FileLogger extends \Psr\Log\AbstractLogger implements \Psr\Log\LoggerInterface +/** + * PSR-3 logger that appends each record as a single line to a file. + * + * Each emitted line follows the format: + * ` [] ` + PHP_EOL. + * + * The destination path supports the following tokens, expanded once at + * construction time using the current process clock: + * `{year}`, `{month}`, `{day}`, `{hour}`, `{minute}`, `{second}` + * + * Use cases for tokens include daily rotation (e.g. `/var/log/app-{year}-{month}-{day}.log`). + * + * Writes are appended with `FILE_APPEND | LOCK_EX` so concurrent processes do not + * interleave bytes within a single record. If the parent directory does not exist, + * it is created recursively (mode `0775`). Write failures are reported through + * {@see error_log()} but never thrown — PSR-3 method calls must not interrupt + * application flow. + */ +class FileLogger extends AbstractLogger { use HelperTrait; - /** @var string */ - protected $path; + /** + * Resolved, token-interpolated absolute or relative path to the log file. + */ + protected string $path; /** - * @param array $options + * @param array{path?: string} $options Required `path`: destination file path. May contain + * `{year}/{month}/{day}/{hour}/{minute}/{second}` tokens. + * + * @throws InvalidArgumentException If `path` is missing, not a string, or empty. */ public function __construct(array $options = []) { - $this->path = $this->interpolate($options['path'], array( - 'year' => date('Y'), - 'month' => date('m'), - 'day' => date('d'), - 'hour' => date('H'), - 'minute' => date('i'), - 'second' => date('s') - )); + if (!isset($options['path']) || !is_string($options['path']) || $options['path'] === '') { + throw new InvalidArgumentException( + 'FileLogger requires a non-empty string "path" option.' + ); + } + + $this->path = $this->interpolate($options['path'], [ + 'year' => date('Y'), + 'month' => date('m'), + 'day' => date('d'), + 'hour' => date('H'), + 'minute' => date('i'), + 'second' => date('s'), + ]); } /** - * @inheritDoc + * @param mixed $level + * @param array $context + * + * @throws \Psr\Log\InvalidArgumentException When `$level` is not a PSR-3 level. */ - public function log($level, $message, array $context = array()) + public function log($level, string|Stringable $message, array $context = []): void { $this->logLevelVerify($level); - $msg = PHP_EOL . $this->getDate('c') . ' [' - . strtoupper($level) - . '] ' . $this->interpolate($message, $context); - @file_put_contents($this->path, $msg, FILE_APPEND); + + $line = $this->getDate('c') + . ' [' . strtoupper((string) $level) . '] ' + . $this->interpolate($message, $context) + . PHP_EOL; + + $this->ensureDirectoryExists(); + + $bytes = @file_put_contents($this->path, $line, FILE_APPEND | LOCK_EX); + if ($bytes === false) { + error_log(sprintf('InitPHP\\Logger\\FileLogger: failed to write log entry to "%s".', $this->path)); + } } + /** + * Returns the resolved log file path (after token interpolation). + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Create the parent directory of {@see $path} if it does not exist. + * + * Failures are reported via `error_log()` without throwing; the subsequent + * `file_put_contents()` call will surface the same condition. + */ + private function ensureDirectoryExists(): void + { + $dir = dirname($this->path); + if ($dir === '' || $dir === '.' || is_dir($dir)) { + return; + } + if (!@mkdir($dir, 0775, true) && !is_dir($dir)) { + error_log(sprintf('InitPHP\\Logger\\FileLogger: failed to create directory "%s".', $dir)); + } + } } diff --git a/src/HelperTrait.php b/src/HelperTrait.php index c9f274b..6381145 100644 --- a/src/HelperTrait.php +++ b/src/HelperTrait.php @@ -1,82 +1,157 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ + +declare(strict_types=1); namespace InitPHP\Logger; -use \DateTime; -use \Psr\Log\InvalidArgumentException; -use \Psr\Log\LogLevel; +use DateTimeImmutable; +use Psr\Log\InvalidArgumentException; +use Psr\Log\LogLevel; +use Stringable; +use Throwable; -use function strtolower; -use function in_array; +use function get_class; use function implode; -use function is_array; +use function in_array; +use function is_bool; use function is_object; +use function is_scalar; +use function is_string; use function method_exists; +use function sprintf; +use function strtolower; use function strtr; +/** + * Internal helper for the bundled handlers. + * + * Provides: + * - PSR-3 placeholder interpolation (including `{exception}` Throwable rendering), + * - PSR-3 log level validation, + * - a consistent timestamp generator. + * + * @internal + */ trait HelperTrait { - /** @var array */ - private $levels = array( + /** + * Canonical PSR-3 log levels, in severity order (highest first). + * + * @var list + */ + private array $levels = [ LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL, LogLevel::ERROR, LogLevel::WARNING, - LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO, - LogLevel::DEBUG - ); + LogLevel::DEBUG, + ]; /** - * @param string $msg - * @param array $context - * @return string + * Replace `{key}` placeholders in `$message` with values from `$context`. + * + * Replacement rules per context value type: + * - `null` → empty string + * - bool → "true" / "false" + * - scalar (int/float/string) → cast to string + * - {@see Stringable} → cast to string + * - {@see Throwable} → "(): in :" + * - everything else (arrays, non-stringable objects) → placeholder is left untouched + * + * Placeholder syntax follows PSR-3 §1.2 (alphanumeric, underscore, dot). + * + * @param string|Stringable $message Message containing zero or more `{placeholder}` tokens. + * @param array $context Replacement values keyed by placeholder name. + * + * @return string The interpolated message. */ - protected function interpolate($msg, array $context = array()) + protected function interpolate(string|Stringable $message, array $context = []): string { - if(empty($context)){ - return $msg; - }else{ - $replace = array(); - foreach ($context as $key => $val) { - if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { - $replace['{' . $key . '}'] = $val; - } + $message = (string) $message; + + if ($context === []) { + return $message; + } + + $replace = []; + foreach ($context as $key => $value) { + if (!is_string($key)) { + continue; } - return strtr($msg, $replace); + $rendered = $this->stringifyContextValue($value); + if ($rendered === null) { + continue; + } + $replace['{' . $key . '}'] = $rendered; } + + return $replace === [] ? $message : strtr($message, $replace); + } + + /** + * Generate a formatted timestamp using {@see DateTimeImmutable}. + * + * @param string $format Any format accepted by {@see DateTimeImmutable::format()}. Defaults to ISO-8601 (`c`). + */ + protected function getDate(string $format = 'c'): string + { + return (new DateTimeImmutable('now'))->format($format); } /** - * @param string $format - * @return string + * Assert that `$level` is one of the eight PSR-3 levels. + * + * Comparison is case-insensitive against the canonical lowercase constants. + * + * @phpstan-assert string $level + * + * @throws InvalidArgumentException When the level is not a recognised PSR-3 level. */ - protected function getDate($format = 'c') + protected function logLevelVerify(mixed $level): void { - return (new DateTime('now'))->format($format); + if (!is_string($level) || !in_array(strtolower($level), $this->levels, true)) { + throw new InvalidArgumentException(sprintf( + 'Unknown log level "%s". Allowed PSR-3 levels are: %s.', + is_string($level) ? $level : get_debug_type($level), + implode(', ', $this->levels) + )); + } } /** - * @param $level - * @return void + * Render a single context value as a string, or return `null` if the value is not renderable. */ - protected function logLevelVerify($level) + private function stringifyContextValue(mixed $value): ?string { - if(in_array(strtolower($level), $this->levels, true) === FALSE){ - throw new InvalidArgumentException('Only ' . implode(', ', $this->levels) . ' levels can be logged.'); + if ($value === null) { + return ''; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_scalar($value)) { + return (string) $value; } + if ($value instanceof Throwable) { + return sprintf( + '%s(%d): %s in %s:%d', + get_class($value), + $value->getCode(), + $value->getMessage(), + $value->getFile(), + $value->getLine() + ); + } + if ($value instanceof Stringable) { + return (string) $value; + } + if (is_object($value) && method_exists($value, '__toString')) { + return (string) $value; + } + + return null; } } diff --git a/src/Logger.php b/src/Logger.php index 8b2af44..f89eeb3 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -1,50 +1,64 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ + +declare(strict_types=1); namespace InitPHP\Logger; -use \Psr\Log\LoggerInterface; +use InvalidArgumentException; +use Psr\Log\AbstractLogger; +use Psr\Log\LoggerInterface; +use Stringable; + +use function array_values; /** - * @method void emergency(string $message, array $context = array()) - * @method void alert(string $message, array $context = array()) - * @method void critical(string $message, array $context = array()) - * @method void error(string $message, array $context = array()) - * @method void warning(string $message, array $context = array()) - * @method void notice(string $message, array $context = array()) - * @method void info(string $message, array $context = array()) - * @method void debug(string $message, array $context = array()) - * @method void log(string $level, string $message, array $context = array()) + * Fan-out multiplexer: forwards every PSR-3 call to a fixed set of inner loggers. + * + * Constructed with one or more {@see LoggerInterface} instances. Each `log()` + * (and, via {@see AbstractLogger}, each `emergency()`/`alert()`/.../`debug()`) + * call is dispatched to every inner logger in the order they were supplied. + * + * Exceptions thrown by an inner logger are *not* caught: PSR-3 requires + * `\Psr\Log\InvalidArgumentException` for unknown levels and otherwise mandates + * silent handling. Callers that need fault-tolerance across handlers should + * wrap individual handlers themselves. */ -class Logger +final class Logger extends AbstractLogger { - /** @var LoggerInterface[] */ - protected $loggers = []; + /** @var list */ + private array $loggers; - public function __construct(...$loggers) + /** + * @throws InvalidArgumentException If no logger is supplied. + */ + public function __construct(LoggerInterface ...$loggers) { - foreach ($loggers as $log) { - if($log instanceof LoggerInterface){ - $this->loggers[] = $log; - } + if ($loggers === []) { + throw new InvalidArgumentException( + 'InitPHP\\Logger\\Logger requires at least one Psr\\Log\\LoggerInterface instance.' + ); } + $this->loggers = array_values($loggers); } - public function __call($name, $arguments) + /** + * @param mixed $level + * @param array $context + */ + public function log($level, string|Stringable $message, array $context = []): void { foreach ($this->loggers as $logger) { - $logger->{$name}(...$arguments); + $logger->log($level, $message, $context); } } + /** + * Returns the inner loggers in the order they were registered. + * + * @return list + */ + public function getLoggers(): array + { + return $this->loggers; + } } diff --git a/src/PDOLogger.php b/src/PDOLogger.php index 944a3eb..c40b295 100644 --- a/src/PDOLogger.php +++ b/src/PDOLogger.php @@ -1,67 +1,104 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ + +declare(strict_types=1); namespace InitPHP\Logger; -use \Psr\Log\AbstractLogger; -use \Psr\Log\InvalidArgumentException; -use \Psr\Log\LoggerInterface; -use \PDO; +use InvalidArgumentException; +use PDO; +use Psr\Log\AbstractLogger; +use Stringable; +use function is_string; +use function preg_match; +use function sprintf; use function strtoupper; -class PDOLogger extends \Psr\Log\AbstractLogger implements \Psr\Log\LoggerInterface +/** + * PSR-3 logger that writes each record as a row in a relational database table. + * + * The target table is expected to expose at least the columns `level`, `message` + * and `date`. A reference MySQL DDL is published in the package README; SQLite + * and PostgreSQL equivalents live in `docs/03-pdo-logger.md`. + * + * Values are bound through prepared statements, so the `message` payload is safe + * against SQL injection. The table identifier itself is validated against the + * regular expression `/^[A-Za-z_][A-Za-z0-9_]*$/` at construction time because + * SQL forbids parameterising identifiers; passing anything outside that grammar + * raises {@see InvalidArgumentException}. + */ +class PDOLogger extends AbstractLogger { use HelperTrait; - /** @var PDO */ - protected $pdo; + /** Regex describing identifiers accepted as table names. */ + private const TABLE_NAME_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]*$/'; - /** @var string */ - protected $table; + protected PDO $pdo; + + protected string $table; /** - * @param array $options + * @param array{pdo?: PDO, table?: string} $options Required keys: + * - `pdo`: an already-configured {@see PDO} instance. + * - `table`: the destination table name, matching {@see TABLE_NAME_PATTERN}. + * + * @throws InvalidArgumentException If options are missing, of the wrong type, or the table name is rejected. */ public function __construct(array $options = []) { - if(!($options['pdo'] instanceof PDO)){ - throw new \InvalidArgumentException('It must be a PDO object.'); + if (!isset($options['pdo'])) { + throw new InvalidArgumentException('PDOLogger requires a "pdo" option.'); + } + if (!$options['pdo'] instanceof PDO) { + throw new InvalidArgumentException('PDOLogger "pdo" option must be a PDO instance.'); } - if(!is_string($options['table'])){ - throw new \InvalidArgumentException('The name of the table where the logs will be kept must be specified as a string.'); + + if (!isset($options['table'])) { + throw new InvalidArgumentException('PDOLogger requires a "table" option.'); + } + if (!is_string($options['table']) || $options['table'] === '') { + throw new InvalidArgumentException('PDOLogger "table" option must be a non-empty string.'); + } + if (preg_match(self::TABLE_NAME_PATTERN, $options['table']) !== 1) { + throw new InvalidArgumentException(sprintf( + 'PDOLogger "table" option "%s" is not a valid SQL identifier; expected %s.', + $options['table'], + self::TABLE_NAME_PATTERN + )); } + $this->pdo = $options['pdo']; $this->table = $options['table']; } - public function __destruct() + /** + * @param mixed $level + * @param array $context + * + * @throws \Psr\Log\InvalidArgumentException When `$level` is not a PSR-3 level. + */ + public function log($level, string|Stringable $message, array $context = []): void { - $this->pdo = null; + $this->logLevelVerify($level); + + $statement = $this->pdo->prepare(sprintf( + 'INSERT INTO %s (level, message, date) VALUES (?, ?, ?)', + $this->table + )); + + $statement->execute([ + strtoupper((string) $level), + $this->interpolate($message, $context), + $this->getDate('Y-m-d H:i:s'), + ]); } /** - * @inheritDoc + * Returns the configured destination table name. */ - public function log($level, $message, array $context = array()) + public function getTable(): string { - $this->logLevelVerify($level); - $date = $this->getDate('Y-m-d H:i:s'); - $level = strtoupper($level); - $msg = $this->interpolate($message, $context); - - $sql = "INSERT INTO " . $this->table . " (level, message, date) VALUES (?,?,?)"; - $query = $this->pdo->prepare($sql); - $query->execute(array($level, $msg, $date)); + return $this->table; } -} \ No newline at end of file +} diff --git a/tests/FileLoggerTest.php b/tests/FileLoggerTest.php new file mode 100644 index 0000000..0be267e --- /dev/null +++ b/tests/FileLoggerTest.php @@ -0,0 +1,222 @@ +tempDir = sys_get_temp_dir() . '/initphp-logger-' . uniqid('', true); + if (!mkdir($this->tempDir) && !is_dir($this->tempDir)) { + throw new RuntimeException('Could not create temp dir for tests.'); + } + } + + protected function tearDown(): void + { + $this->removeDirectory($this->tempDir); + } + + public function testConstructorThrowsWhenPathMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('FileLogger requires a non-empty string "path" option.'); + + new FileLogger([]); + } + + public function testConstructorThrowsWhenPathIsEmptyString(): void + { + $this->expectException(InvalidArgumentException::class); + + new FileLogger(['path' => '']); + } + + public function testConstructorThrowsWhenPathIsNotString(): void + { + $this->expectException(InvalidArgumentException::class); + + /** @phpstan-ignore-next-line - intentional bad type */ + new FileLogger(['path' => 123]); + } + + public function testLogWritesSingleLineEndingWithNewline(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->info('hello world'); + + $contents = (string) file_get_contents($path); + $this->assertStringEndsWith(PHP_EOL, $contents); + $this->assertSame(1, substr_count($contents, PHP_EOL)); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2} \[INFO\] hello world\r?\n$/', + $contents + ); + } + + public function testFirstLineDoesNotBeginWithBlankLine(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->error('boom'); + + $contents = (string) file_get_contents($path); + $this->assertSame('2', substr($contents, 0, 1) === '2' ? '2' : substr($contents, 0, 1), 'must start with timestamp digit'); + $this->assertStringStartsNotWith(PHP_EOL, $contents); + } + + public function testMultipleLogsAppendOnePerLine(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->info('first'); + $logger->warning('second'); + $logger->error('third'); + + $lines = file($path, FILE_IGNORE_NEW_LINES); + $this->assertIsArray($lines); + $this->assertCount(3, $lines); + $this->assertStringContainsString('[INFO] first', $lines[0]); + $this->assertStringContainsString('[WARNING] second', $lines[1]); + $this->assertStringContainsString('[ERROR] third', $lines[2]); + } + + public function testContextPlaceholdersAreInterpolated(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->error('User {username} hit a {kind} error.', ['username' => 'john', 'kind' => 'fatal']); + + $contents = (string) file_get_contents($path); + $this->assertStringContainsString('User john hit a fatal error.', $contents); + } + + public function testInterpolationHandlesNullBoolAndStringable(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $stringable = new class () implements \Stringable { + public function __toString(): string + { + return 'STR'; + } + }; + + $logger->info('a={a} b={b} c={c} d={d}', [ + 'a' => null, + 'b' => true, + 'c' => false, + 'd' => $stringable, + ]); + + $contents = (string) file_get_contents($path); + $this->assertStringContainsString('a= b=true c=false d=STR', $contents); + } + + public function testInterpolationRendersThrowable(): void + { + $path = $this->tempDir . '/app.log'; + $logger = new FileLogger(['path' => $path]); + + $exception = new \RuntimeException('disk full', 42); + $logger->critical('failure: {exception}', ['exception' => $exception]); + + $contents = (string) file_get_contents($path); + $this->assertStringContainsString('RuntimeException(42): disk full in', $contents); + } + + public function testUnknownLevelThrowsPsrInvalidArgumentException(): void + { + $logger = new FileLogger(['path' => $this->tempDir . '/x.log']); + + $this->expectException(PsrInvalidArgumentException::class); + + $logger->log('verbose', 'nope'); + } + + public function testPathTokensAreInterpolatedAtConstruction(): void + { + $template = $this->tempDir . '/app-{year}-{month}-{day}.log'; + $logger = new FileLogger(['path' => $template]); + + $expected = $this->tempDir . '/app-' . date('Y') . '-' . date('m') . '-' . date('d') . '.log'; + $this->assertSame($expected, $logger->getPath()); + } + + public function testParentDirectoryIsCreatedAutomatically(): void + { + $path = $this->tempDir . '/nested/deep/app.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->info('hi'); + + $this->assertFileExists($path); + $this->assertDirectoryExists($this->tempDir . '/nested/deep'); + } + + public function testAllPsr3LevelsAreAccepted(): void + { + $path = $this->tempDir . '/levels.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->emergency('a'); + $logger->alert('b'); + $logger->critical('c'); + $logger->error('d'); + $logger->warning('e'); + $logger->notice('f'); + $logger->info('g'); + $logger->debug('h'); + + $contents = (string) file_get_contents($path); + foreach (['EMERGENCY', 'ALERT', 'CRITICAL', 'ERROR', 'WARNING', 'NOTICE', 'INFO', 'DEBUG'] as $needle) { + $this->assertStringContainsString('[' . $needle . ']', $contents); + } + } + + public function testLogLevelConstantsAreAccepted(): void + { + $path = $this->tempDir . '/const.log'; + $logger = new FileLogger(['path' => $path]); + + $logger->log(LogLevel::WARNING, 'caution'); + + $this->assertStringContainsString('[WARNING] caution', (string) file_get_contents($path)); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + $items = scandir($dir); + if ($items === false) { + return; + } + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $path = $dir . '/' . $item; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/HelperTraitTest.php b/tests/HelperTraitTest.php new file mode 100644 index 0000000..55e8687 --- /dev/null +++ b/tests/HelperTraitTest.php @@ -0,0 +1,178 @@ + \Closure::fromCallable([$instance, 'publicInterpolate']), + 'verify' => \Closure::fromCallable([$instance, 'publicVerify']), + 'date' => \Closure::fromCallable([$instance, 'publicGetDate']), + ]; + } + + public function testInterpolateReturnsMessageUnchangedWhenContextEmpty(): void + { + $h = $this->helper(); + $this->assertSame('no replace', ($h->interpolate)('no replace', [])); + } + + public function testInterpolateReplacesNullBoolScalarStringable(): void + { + $h = $this->helper(); + + $stringable = new class () implements \Stringable { + public function __toString(): string + { + return 'S'; + } + }; + + $result = ($h->interpolate)( + 'a={a} b={b} c={c} d={d} e={e} f={f}', + [ + 'a' => null, + 'b' => true, + 'c' => false, + 'd' => 'hi', + 'e' => 42, + 'f' => $stringable, + ] + ); + + $this->assertSame('a= b=true c=false d=hi e=42 f=S', $result); + } + + public function testInterpolateLeavesUnsupportedTypesUntouched(): void + { + $h = $this->helper(); + + $result = ($h->interpolate)('arr={arr} obj={obj}', [ + 'arr' => ['x', 'y'], + 'obj' => new \stdClass(), + ]); + + $this->assertSame('arr={arr} obj={obj}', $result); + } + + public function testInterpolateRendersThrowable(): void + { + $h = $this->helper(); + $exception = new \LogicException('bad state', 7); + + $result = ($h->interpolate)('{exception}', ['exception' => $exception]); + + $this->assertStringStartsWith('LogicException(7): bad state in ', $result); + } + + public function testInterpolateAcceptsStringableMessage(): void + { + $h = $this->helper(); + $msg = new class () implements \Stringable { + public function __toString(): string + { + return 'hello {name}'; + } + }; + + $this->assertSame('hello world', ($h->interpolate)($msg, ['name' => 'world'])); + } + + public function testInterpolateIgnoresNonStringKeys(): void + { + $h = $this->helper(); + + $result = ($h->interpolate)('a={0} b={b}', [0 => 'X', 'b' => 'Y']); + + $this->assertSame('a={0} b=Y', $result); + } + + public function testGetDateProducesIso8601ByDefault(): void + { + $h = $this->helper(); + $value = ($h->date)(); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2}$/', + $value + ); + } + + public function testGetDateRespectsCustomFormat(): void + { + $h = $this->helper(); + $value = ($h->date)('Y-m-d'); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $value); + } + + public function testLogLevelVerifyAcceptsAllEightPsr3Levels(): void + { + $h = $this->helper(); + + foreach ( + [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ] as $level + ) { + ($h->verify)($level); + } + $this->expectNotToPerformAssertions(); + } + + public function testLogLevelVerifyIsCaseInsensitive(): void + { + $h = $this->helper(); + ($h->verify)('ERROR'); + ($h->verify)('Warning'); + $this->expectNotToPerformAssertions(); + } + + public function testLogLevelVerifyRejectsUnknownString(): void + { + $h = $this->helper(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown log level "verbose"'); + + ($h->verify)('verbose'); + } + + public function testLogLevelVerifyRejectsNonStringValues(): void + { + $h = $this->helper(); + + $this->expectException(InvalidArgumentException::class); + + ($h->verify)(123); + } +} diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php new file mode 100644 index 0000000..3cc0031 --- /dev/null +++ b/tests/LoggerTest.php @@ -0,0 +1,88 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('requires at least one Psr\\Log\\LoggerInterface'); + + new Logger(); + } + + public function testConstructorRejectsNonLoggerInterfaceArguments(): void + { + $this->expectException(TypeError::class); + + /** @phpstan-ignore-next-line - intentional bad type */ + new Logger(new \stdClass()); + } + + public function testLogIsFanOutToEveryInnerLogger(): void + { + $a = $this->createMock(LoggerInterface::class); + $b = $this->createMock(LoggerInterface::class); + + $a->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, 'oops', ['k' => 'v']); + $b->expects($this->once()) + ->method('log') + ->with(LogLevel::ERROR, 'oops', ['k' => 'v']); + + $logger = new Logger($a, $b); + $logger->log(LogLevel::ERROR, 'oops', ['k' => 'v']); + } + + public function testLevelHelpersFanOutThroughLogMethod(): void + { + $inner = $this->createMock(LoggerInterface::class); + $inner->expects($this->once()) + ->method('log') + ->with(LogLevel::WARNING, 'hi', []); + + $logger = new Logger($inner); + $logger->warning('hi'); + } + + public function testImplementsPsrLoggerInterface(): void + { + $logger = new Logger(new NullLogger()); + $this->assertInstanceOf(LoggerInterface::class, $logger); + } + + public function testGetLoggersReturnsInjectedInstancesInOrder(): void + { + $a = new NullLogger(); + $b = new NullLogger(); + + $logger = new Logger($a, $b); + + $this->assertSame([$a, $b], $logger->getLoggers()); + } + + public function testInnerLoggerExceptionIsPropagated(): void + { + $inner = $this->createMock(LoggerInterface::class); + $inner->method('log')->willThrowException(new \RuntimeException('boom')); + + $logger = new Logger($inner); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('boom'); + + $logger->info('hi'); + } +} diff --git a/tests/PDOLoggerTest.php b/tests/PDOLoggerTest.php new file mode 100644 index 0000000..ec91e8e --- /dev/null +++ b/tests/PDOLoggerTest.php @@ -0,0 +1,174 @@ +pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $this->pdo->exec(<<<'SQL' + CREATE TABLE logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + message TEXT NOT NULL, + date TEXT NOT NULL + ) + SQL); + } + + public function testConstructorThrowsWhenPdoMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('PDOLogger requires a "pdo" option.'); + + new PDOLogger(['table' => 'logs']); + } + + public function testConstructorThrowsWhenPdoIsWrongType(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('PDOLogger "pdo" option must be a PDO instance.'); + + /** @phpstan-ignore-next-line */ + new PDOLogger(['pdo' => 'not-a-pdo', 'table' => 'logs']); + } + + public function testConstructorThrowsWhenTableMissing(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('PDOLogger requires a "table" option.'); + + new PDOLogger(['pdo' => $this->pdo]); + } + + public function testConstructorThrowsWhenTableIsEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('non-empty string'); + + new PDOLogger(['pdo' => $this->pdo, 'table' => '']); + } + + public function testConstructorRejectsTableNamesWithIllegalCharacters(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('is not a valid SQL identifier'); + + new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs; DROP TABLE users']); + } + + public function testConstructorRejectsTableNamesStartingWithDigit(): void + { + $this->expectException(InvalidArgumentException::class); + + new PDOLogger(['pdo' => $this->pdo, 'table' => '123logs']); + } + + public function testConstructorAcceptsValidTableNames(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'application_logs_v2']); + $this->assertSame('application_logs_v2', $logger->getTable()); + } + + public function testLogInsertsRowWithUppercaseLevel(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + $logger->error('something broke'); + + $row = $this->fetchOneRow('SELECT level, message, date FROM logs'); + $this->assertSame('ERROR', $row['level']); + $this->assertSame('something broke', $row['message']); + $this->assertIsString($row['date']); + $this->assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $row['date']); + } + + public function testLogInterpolatesContextPlaceholders(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + $logger->warning('user {id} exceeded {limit} requests', ['id' => 7, 'limit' => 100]); + + $row = $this->fetchOneRow('SELECT message FROM logs'); + $this->assertSame('user 7 exceeded 100 requests', $row['message']); + } + + public function testUnknownLevelThrowsPsrInvalidArgumentException(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + $this->expectException(PsrInvalidArgumentException::class); + + $logger->log('verbose', 'no such level'); + } + + public function testAllPsr3LevelsAreAccepted(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + foreach ( + [ + LogLevel::EMERGENCY, + LogLevel::ALERT, + LogLevel::CRITICAL, + LogLevel::ERROR, + LogLevel::WARNING, + LogLevel::NOTICE, + LogLevel::INFO, + LogLevel::DEBUG, + ] as $level + ) { + $logger->log($level, 'msg-' . $level); + } + + $this->assertSame(8, $this->countRows()); + } + + public function testMessageWithSqlMetaCharactersIsNotInterpretedAsSql(): void + { + $logger = new PDOLogger(['pdo' => $this->pdo, 'table' => 'logs']); + + $payload = "'; DROP TABLE logs; --"; + $logger->info($payload); + + $row = $this->fetchOneRow('SELECT message FROM logs'); + $this->assertSame($payload, $row['message']); + + // logs table must still exist + $this->assertSame(1, $this->countRows()); + } + + /** + * @return array + */ + private function fetchOneRow(string $sql): array + { + $stmt = $this->pdo->query($sql); + $this->assertInstanceOf(\PDOStatement::class, $stmt); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + $this->assertIsArray($row); + + return $row; + } + + private function countRows(): int + { + $stmt = $this->pdo->query('SELECT COUNT(*) FROM logs'); + $this->assertInstanceOf(\PDOStatement::class, $stmt); + + return (int) $stmt->fetchColumn(); + } +}