From 9dc093a14ced205391a8e0aa810057eb56eea283 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 27 Jun 2026 09:00:05 -0300 Subject: [PATCH 1/4] refactor: Conform the library to the tiny-blocks rules and skill canon. Align the package with the .claude/rules and the tiny-blocks-create skill: restore the canonical tooling, refactor src to the coding and architecture rules, rewrite the tests to the BDD, naming, and ordering rules, and fix the README to the documentation rules. - Restore canonical config: add phpcs.xml, reduce composer.json to the five standard scripts with keywords and the dev-dependency floor, run PHPStan at level max over src and tests, and align phpunit, infection, the editor and git config files, the Makefile, and ci.yml. - Refactor src: move LogLevel under Internal, keep Internal types out of the public API through StructuredLogger::from, make the builder an immutable final readonly value, and apply the naming, ordering, self-reference, and format-string rules. An unknown PSR-3 level now raises the dedicated UnknownLogLevel instead of a native error. - Rewrite the tests onto an InMemoryStream fixture with testXxxWhenYyyThenZzz names and corrected BDD steps, covering the unknown-level and stderr fallback paths through the public API. --- .editorconfig | 1 + .gitattributes | 13 +- .github/workflows/ci.yml | 46 +- .gitignore | 30 +- Makefile | 33 +- README.md | 99 +- composer.json | 41 +- infection.json.dist | 9 +- phpcs.xml | 7 + phpstan.neon.dist | 59 +- phpunit.xml | 22 +- src/Exceptions/UnknownLogLevel.php | 14 + src/Internal/LogFormatter.php | 31 +- src/{ => Internal}/LogLevel.php | 6 +- src/Internal/Redactor/Redactions.php | 9 +- src/Internal/Redactor/Redactor.php | 11 +- src/Internal/Stream/LogStream.php | 3 +- src/LogContext.php | 9 + src/Redaction.php | 8 +- src/Redactions/DocumentRedaction.php | 25 +- src/Redactions/EmailRedaction.php | 24 +- src/Redactions/NameRedaction.php | 25 +- src/Redactions/PasswordRedaction.php | 21 +- src/Redactions/PhoneRedaction.php | 25 +- src/StructuredLogger.php | 78 +- src/StructuredLoggerBuilder.php | 123 +- tests/InMemoryStream.php | 44 + tests/Internal/Stream/LogStreamTest.php | 22 - tests/StructuredLoggerTest.php | 1379 ++++++++++++----------- 29 files changed, 1320 insertions(+), 897 deletions(-) create mode 100644 phpcs.xml create mode 100644 src/Exceptions/UnknownLogLevel.php rename src/{ => Internal}/LogLevel.php (88%) create mode 100644 tests/InMemoryStream.php delete mode 100644 tests/Internal/Stream/LogStreamTest.php diff --git a/.editorconfig b/.editorconfig index 73e3c9a..be5640e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,7 @@ charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space +max_line_length = 120 insert_final_newline = true trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes index 744a43b..2bd9baa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,21 +2,18 @@ *.php text diff=php -# Dev-only — excluded from the Packagist tarball +# Dev-only, excluded from the Packagist tarball /.github export-ignore /tests export-ignore /.claude export-ignore +/CLAUDE.md export-ignore /.editorconfig export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/phpcs.xml export-ignore /phpunit.xml export-ignore -/phpunit.xml.dist export-ignore -/phpstan.neon export-ignore /phpstan.neon.dist export-ignore -/phpcs.xml export-ignore -/phpcs.xml.dist export-ignore -/infection.json export-ignore /infection.json.dist export-ignore /Makefile export-ignore -/CONTRIBUTING.md export-ignore -/CHANGES.md export-ignore +/reports export-ignore +/.phpunit.cache export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71e59e0..728bb3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,26 +3,44 @@ name: CI on: pull_request: +concurrency: + group: ci-${{ github.event.pull_request.number }} + cancel-in-progress: true + permissions: contents: read -env: - PHP_VERSION: '8.5' - jobs: + resolve-php-version: + name: Resolve PHP version + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + php-version: ${{ steps.config.outputs.php-version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Resolve PHP version from composer.json + id: config + run: | + version=$(jq -r '.require.php' composer.json | grep -oP '\d+\.\d+' | head -1) + echo "php-version=$version" >> "$GITHUB_OUTPUT" + build: name: Build + needs: resolve-php-version runs-on: ubuntu-latest - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Validate composer.json run: composer validate --no-interaction @@ -40,18 +58,18 @@ jobs: auto-review: name: Auto review + needs: [resolve-php-version, build] runs-on: ubuntu-latest - needs: build - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 @@ -64,18 +82,18 @@ jobs: tests: name: Tests + needs: [resolve-php-version, auto-review] runs-on: ubuntu-latest - needs: auto-review - + timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v6 - - name: Configure PHP + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: ${{ env.PHP_VERSION }} tools: composer:2 + php-version: ${{ needs.resolve-php-version.outputs.php-version }} - name: Download vendor artifact from build uses: actions/download-artifact@v8 diff --git a/.gitignore b/.gitignore index bd5baa3..c8f4364 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,28 @@ -# Agent/IDE -.claude/ -.idea/ -.vscode/ -.cursor/ - -# Composer +# PHP dependencies /vendor/ composer.lock -# PHPUnit / coverage +# Local config overrides (committed baselines are the .dist files) +/phpstan.neon +/infection.json + +# Tooling cache .phpunit.cache/ .phpunit.result.cache -report/ -coverage/ + +# Coverage and reports build/ +reports/ +coverage/ +infection.log + +# Editors and agents +.idea/ +.cursor/ +.vscode/ +/.claude/settings.local.json # OS -.DS_Store Thumbs.db +.DS_Store +Desktop.ini diff --git a/Makefile b/Makefile index 07acc3b..90ab50d 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,9 @@ ifeq ($(ARCH),arm64) PLATFORM := --platform=linux/amd64 endif -DOCKER_RUN = docker run ${PLATFORM} --rm -it --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine +TTY := $(shell [ -t 0 ] && echo -it) + +DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:/app -w /app gustavofreze/php:8.5-alpine RESET := \033[0m GREEN := \033[0;32m @@ -16,28 +18,27 @@ YELLOW := \033[0;33m .PHONY: configure configure: ## Configure development environment - @${DOCKER_RUN} composer update --optimize-autoloader - @${DOCKER_RUN} composer normalize + @${DOCKER_RUN} composer configure + +.PHONY: configure-and-update +configure-and-update: ## Configure development environment and update dependencies + @${DOCKER_RUN} composer configure-and-update -.PHONY: test -test: ## Run all tests with coverage +.PHONY: tests +tests: ## Run unit and mutation tests with coverage @${DOCKER_RUN} composer tests .PHONY: test-file test-file: ## Run tests for a specific file (usage: make test-file FILE=ClassNameTest) @${DOCKER_RUN} composer test-file ${FILE} -.PHONY: test-no-coverage -test-no-coverage: ## Run all tests without coverage - @${DOCKER_RUN} composer tests-no-coverage - .PHONY: review -review: ## Run static code analysis +review: ## Run lint and static analysis @${DOCKER_RUN} composer review .PHONY: show-reports -show-reports: ## Open static analysis reports (e.g., coverage, lints) in the browser - @sensible-browser report/coverage/coverage-html/index.html report/coverage/mutation-report.html +show-reports: ## Open coverage and mutation reports in the browser + @sensible-browser reports/coverage/coverage-html/index.html reports/coverage/mutation-report.html .PHONY: show-outdated show-outdated: ## Show outdated direct dependencies @@ -46,18 +47,18 @@ show-outdated: ## Show outdated direct dependencies .PHONY: clean clean: ## Remove dependencies and generated artifacts @sudo chown -R ${USER}:${USER} ${PWD} - @rm -rf report vendor .phpunit.cache *.lock + @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help -help: ## Display this help message +help: ## Display this help message @echo "Usage: make [target]" @echo "" @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" - @grep -E '^(configure):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(configure|configure-and-update):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*? ## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Testing$$(printf '$(RESET)')" - @grep -E '^(test|test-file|test-no-coverage):.*?## .*$$' $(MAKEFILE_LIST) \ + @grep -E '^(tests|test-file):.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' @echo "" @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" diff --git a/README.md b/README.md index 60b4149..4492ac5 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,23 @@ # Logger -[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) +[![License](https://img.shields.io/badge/license-MIT-green)](https://github.com/tiny-blocks/logger/blob/main/LICENSE) * [Overview](#overview) * [Installation](#installation) * [How to use](#how-to-use) - * [Basic logging](#basic-logging) - * [Correlation tracking](#correlation-tracking) - * [Sensitive data redaction](#sensitive-data-redaction) - * [Custom log template](#custom-log-template) + + [Basic logging](#basic-logging) + + [Correlation tracking](#correlation-tracking) + - [At creation time](#at-creation-time) + - [Derived from an existing logger](#derived-from-an-existing-logger) + + [Sensitive data redaction](#sensitive-data-redaction) + - [Document redaction](#document-redaction) + - [Email redaction](#email-redaction) + - [Phone redaction](#phone-redaction) + - [Password redaction](#password-redaction) + - [Name redaction](#name-redaction) + - [Composing multiple redactions](#composing-multiple-redactions) + - [Custom redaction](#custom-redaction) + + [Custom log template](#custom-log-template) * [License](#license) * [Contributing](#contributing) @@ -38,6 +47,10 @@ Create a logger with `StructuredLogger::create()` and use the fluent builder to supported: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, and `emergency`. ```php +info(message: 'payment.started', context: ['amount' => 100.50]); #### Derived from an existing logger ```php +info(message: 'kyc.verified', context: ['document' => '12345678900']); With custom fields and visible length: ```php +info(message: 'user.registered', context: ['email' => 'john@example.com With custom fields: ```php +info(message: 'sms.sent', context: ['phone' => '+5511999887766']); With custom fields: ```php +info(message: 'login.attempt', context: ['password' => '123']); With custom fields and fixed mask length: ```php +info(message: 'user.created', context: ['name' => 'Gustavo']); With custom fields and visible length: ```php +info(message: 'user.registered', context: [ Implement the `Redaction` interface to create your own strategy: ```php + $value) { + foreach ($payload as $key => $value) { if (is_array($value)) { - $data[$key] = $this->redact(data: $value); + $payload[$key] = $this->redact(payload: $value); continue; } if ($key === 'token' && is_string($value)) { - $data[$key] = '***REDACTED***'; + $payload[$key] = '***REDACTED***'; } } - return $data; + return $payload; } } ``` @@ -296,7 +365,11 @@ final readonly class TokenRedaction implements Redaction Then add it to the logger: ```php -use TinyBlocks\Logger\StructuredLogger; +withComponent(component: 'auth-service') @@ -319,6 +392,10 @@ You can replace it with any `sprintf` compatible template that accepts six strin correlationId, level, key, data): ```php + + + Code style for the tiny-blocks library. + + src + tests + diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e81e48d..b00979a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,9 +1,52 @@ parameters: - paths: - - src - level: 9 - tmpDir: report/phpstan - ignoreErrors: - - '#mixed#' - - '#type specified in iterable type array#' - reportUnmatchedIgnoredErrors: false + level: max + paths: + - src + - tests + reportUnmatchedIgnoredErrors: true + ignoreErrors: + # PSR-3 LoggerInterface::log() types $level as mixed; the library casts it to string at that + # boundary to resolve the log level. The mixed origin is the PSR contract, not the library. + - identifier: cast.string + path: src/StructuredLogger.php + # Internal collaborators and private factory constructors carry plain array shapes. Native PHP + # cannot annotate the value type, and PHPDoc is prohibited on Internal types and constructors. + - identifier: missingType.iterableValue + path: src/Internal/LogFormatter.php + - identifier: missingType.iterableValue + path: src/Internal/Redactor/Redactions.php + - identifier: missingType.iterableValue + path: src/Internal/Redactor/Redactor.php + - identifier: missingType.iterableValue + path: src/Redactions/*.php + - identifier: missingType.iterableValue + path: src/StructuredLoggerBuilder.php + # The recursive redactor and the reduce pipeline pass a plain array into the + # array contract of Redaction::redact; the values originate as mixed log + # context, an irreducible boundary shape inside Internal collaborators. + - identifier: argument.type + path: src/Internal/Redactor/Redactions.php + - identifier: argument.type + path: src/Internal/Redactor/Redactor.php + # LogStream wraps a stream resource the language can only promote as mixed; fwrite then + # receives that mixed handle. Internal collaborator with intrinsic resource state. + - identifier: argument.type + path: src/Internal/Stream/LogStream.php + # The builder accumulates Redaction instances in a variadic pass-through array and spreads + # them into StructuredLogger::from(); the element type cannot be annotated on the promoted + # constructor parameter without prohibited PHPDoc. + - identifier: argument.type + path: src/StructuredLoggerBuilder.php + # json_decode() returns mixed, so the deep recursive redaction test array-accesses a mixed + # value when reading the decoded payload. Descriptive PHPDoc is prohibited in tests/, so the + # irreducible mixed-origin offset access is suppressed for this file only. + - identifier: offsetAccess.nonOffsetAccessible + path: tests/StructuredLoggerTest.php + # The mixed values read from that decoded payload feed string-typed PHPUnit assertion + # parameters (assertStringStartsWith, assertStringContainsString, ...). Same mixed origin. + - identifier: argument.type + path: tests/StructuredLoggerTest.php + # The password data provider returns a list of single-argument rows. Its iterable value type + # cannot be expressed without PHPDoc, which tests/ forbids, so it is suppressed for this file. + - identifier: missingType.iterableValue + path: tests/StructuredLoggerTest.php diff --git a/phpunit.xml b/phpunit.xml index 40c80a2..9cc6d13 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,15 @@ + failOnDeprecation="true" + failOnNotice="true" + failOnPhpunitDeprecation="true" + failOnRisky="true" + failOnWarning="true"> @@ -23,15 +25,15 @@ - - - - + + + + - + diff --git a/src/Exceptions/UnknownLogLevel.php b/src/Exceptions/UnknownLogLevel.php new file mode 100644 index 0000000..5c0ce34 --- /dev/null +++ b/src/Exceptions/UnknownLogLevel.php @@ -0,0 +1,14 @@ + '\\n', "\r" => '\\r', "\t" => '\\t']); + } + + public static function fromTemplate(string $template, string $component): LogFormatter { - return new LogFormatter(component: $component, template: self::DEFAULT_TEMPLATE); + return new LogFormatter(template: $template, component: $component); } - public static function fromTemplate(string $component, string $template): LogFormatter + public static function fromComponent(string $component): LogFormatter { - return new LogFormatter(component: $component, template: $template); + return new LogFormatter(template: self::DEFAULT_TEMPLATE, component: $component); } - public function format(string $key, array $data, LogLevel $level, ?LogContext $context = null): string + public function format(string $key, LogLevel $level, array $payload, ?LogContext $context = null): string { $timestamp = new DateTimeImmutable()->format(DateTimeInterface::ATOM); $correlationId = is_null($context) ? self::EMPTY_CORRELATION_ID : $context->correlationId; try { $encodedData = json_encode( - $data, + $payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); } catch (JsonException) { @@ -47,16 +51,11 @@ public function format(string $key, array $data, LogLevel $level, ?LogContext $c return sprintf( $this->template, $timestamp, - self::sanitize(value: $this->component), - self::sanitize(value: $correlationId), + LogFormatter::sanitize(value: $this->component), + LogFormatter::sanitize(value: $correlationId), $level->value, - self::sanitize(value: $key), + LogFormatter::sanitize(value: $key), $encodedData ); } - - private static function sanitize(string $value): string - { - return strtr($value, ["\n" => '\\n', "\r" => '\\r', "\t" => '\\t']); - } } diff --git a/src/LogLevel.php b/src/Internal/LogLevel.php similarity index 88% rename from src/LogLevel.php rename to src/Internal/LogLevel.php index 954d60d..0d541fd 100644 --- a/src/LogLevel.php +++ b/src/Internal/LogLevel.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace TinyBlocks\Logger; +namespace TinyBlocks\Logger\Internal; enum LogLevel: string { case INFO = 'INFO'; - case ERROR = 'ERROR'; - case DEBUG = 'DEBUG'; case ALERT = 'ALERT'; + case DEBUG = 'DEBUG'; + case ERROR = 'ERROR'; case NOTICE = 'NOTICE'; case WARNING = 'WARNING'; case CRITICAL = 'CRITICAL'; diff --git a/src/Internal/Redactor/Redactions.php b/src/Internal/Redactor/Redactions.php index bc699d1..47100af 100644 --- a/src/Internal/Redactor/Redactions.php +++ b/src/Internal/Redactor/Redactions.php @@ -7,13 +7,16 @@ use TinyBlocks\Collection\Collection; use TinyBlocks\Logger\Redaction; +/** + * @extends Collection + */ final class Redactions extends Collection { - public function applyTo(array $data): array + public function applyTo(array $payload): array { return $this->reduce( - accumulator: static fn(array $carry, Redaction $redaction): array => $redaction->redact(data: $carry), - initial: $data + accumulator: static fn(array $carry, Redaction $redaction): array => $redaction->redact(payload: $carry), + initial: $payload ); } } diff --git a/src/Internal/Redactor/Redactor.php b/src/Internal/Redactor/Redactor.php index 28e2ca3..c60a276 100644 --- a/src/Internal/Redactor/Redactor.php +++ b/src/Internal/Redactor/Redactor.php @@ -9,24 +9,23 @@ final readonly class Redactor implements Redaction { - /** @param string[] $fields */ public function __construct(private array $fields, private Closure $maskingFunction) { } - public function redact(array $data): array + public function redact(array $payload): array { - foreach ($data as $key => $value) { + foreach ($payload as $key => $value) { if (is_array($value)) { - $data[$key] = $this->redact(data: $value); + $payload[$key] = $this->redact(payload: $value); continue; } if (in_array($key, $this->fields, true) && is_string($value)) { - $data[$key] = ($this->maskingFunction)($value); + $payload[$key] = ($this->maskingFunction)($value); } } - return $data; + return $payload; } } diff --git a/src/Internal/Stream/LogStream.php b/src/Internal/Stream/LogStream.php index 2537757..00875b1 100644 --- a/src/Internal/Stream/LogStream.php +++ b/src/Internal/Stream/LogStream.php @@ -6,7 +6,6 @@ final readonly class LogStream { - /** @var resource */ private mixed $resource; private function __construct(mixed $resource) @@ -16,7 +15,7 @@ private function __construct(mixed $resource) public static function from(mixed $resource = null): LogStream { - if ($resource !== null) { + if (!is_null($resource)) { return new LogStream(resource: $resource); } diff --git a/src/LogContext.php b/src/LogContext.php index bbe243d..6e5999c 100644 --- a/src/LogContext.php +++ b/src/LogContext.php @@ -4,12 +4,21 @@ namespace TinyBlocks\Logger; +/** + * Correlation context carried across log entries. + */ final readonly class LogContext { private function __construct(public string $correlationId) { } + /** + * Creates a LogContext from a correlation identifier. + * + * @param string $correlationId The correlation identifier shared across related log entries. + * @return LogContext The created instance. + */ public static function from(string $correlationId): LogContext { return new LogContext(correlationId: $correlationId); diff --git a/src/Redaction.php b/src/Redaction.php index c7fd158..e74253a 100644 --- a/src/Redaction.php +++ b/src/Redaction.php @@ -13,10 +13,10 @@ interface Redaction { /** - * Redacts sensitive data from the given array and returns the modified data. + * Redacts sensitive data from the given payload and returns the modified payload. * - * @param array $data The data to be redacted. - * @return array The redacted data. + * @param array $payload The payload to be redacted. + * @return array The redacted payload. */ - public function redact(array $data): array; + public function redact(array $payload): array; } diff --git a/src/Redactions/DocumentRedaction.php b/src/Redactions/DocumentRedaction.php index d84432e..f824ef7 100644 --- a/src/Redactions/DocumentRedaction.php +++ b/src/Redactions/DocumentRedaction.php @@ -7,6 +7,9 @@ use TinyBlocks\Logger\Internal\Redactor\Redactor; use TinyBlocks\Logger\Redaction; +/** + * Masks document field values, keeping a configurable number of trailing characters visible. + */ final readonly class DocumentRedaction implements Redaction { private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 3; @@ -20,8 +23,10 @@ private function __construct(array $fields, int $visibleSuffixLength) maskingFunction: static function (string $value) use ($visibleSuffixLength): string { $length = mb_strlen($value, 'UTF-8'); $maskedLength = max(0, $length - $visibleSuffixLength); + $template = '%s%s'; + return sprintf( - '%s%s', + $template, str_repeat('*', $maskedLength), mb_substr($value, -$visibleSuffixLength, null, 'UTF-8') ); @@ -29,18 +34,30 @@ private function __construct(array $fields, int $visibleSuffixLength) ); } + /** + * Creates a DocumentRedaction from the fields to mask and the number of visible trailing characters. + * + * @param string[] $fields The field names whose values are masked. + * @param int $visibleSuffixLength The number of trailing characters left visible. + * @return DocumentRedaction The created instance. + */ public static function from(array $fields, int $visibleSuffixLength): DocumentRedaction { return new DocumentRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); } + /** + * Builds a DocumentRedaction with the default document field and visible suffix length. + * + * @return DocumentRedaction The created instance. + */ public static function default(): DocumentRedaction { - return self::from(fields: ['document'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + return DocumentRedaction::from(fields: ['document'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); } - public function redact(array $data): array + public function redact(array $payload): array { - return $this->redactor->redact(data: $data); + return $this->redactor->redact(payload: $payload); } } diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php index 789d789..1764de4 100644 --- a/src/Redactions/EmailRedaction.php +++ b/src/Redactions/EmailRedaction.php @@ -7,6 +7,9 @@ use TinyBlocks\Logger\Internal\Redactor\Redactor; use TinyBlocks\Logger\Redaction; +/** + * Masks the local part of email field values, keeping a configurable visible prefix and the domain. + */ final readonly class EmailRedaction implements Redaction { private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 2; @@ -28,24 +31,37 @@ private function __construct(array $fields, int $visiblePrefixLength) $localPart = mb_substr($value, 0, $atPosition, 'UTF-8'); $maskedSuffix = str_repeat('*', max(0, mb_strlen($localPart, 'UTF-8') - $visiblePrefixLength)); $visiblePrefix = mb_substr($localPart, 0, $visiblePrefixLength, 'UTF-8'); + $template = '%s%s%s'; - return sprintf('%s%s%s', $visiblePrefix, $maskedSuffix, $domain); + return sprintf($template, $visiblePrefix, $maskedSuffix, $domain); } ); } + /** + * Creates an EmailRedaction from the fields to mask and the number of visible leading characters. + * + * @param string[] $fields The field names whose values are masked. + * @param int $visiblePrefixLength The number of leading characters of the local part left visible. + * @return EmailRedaction The created instance. + */ public static function from(array $fields, int $visiblePrefixLength): EmailRedaction { return new EmailRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength); } + /** + * Builds an EmailRedaction with the default email field and visible prefix length. + * + * @return EmailRedaction The created instance. + */ public static function default(): EmailRedaction { - return self::from(fields: ['email'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); + return EmailRedaction::from(fields: ['email'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); } - public function redact(array $data): array + public function redact(array $payload): array { - return $this->redactor->redact(data: $data); + return $this->redactor->redact(payload: $payload); } } diff --git a/src/Redactions/NameRedaction.php b/src/Redactions/NameRedaction.php index 94b015b..4b5d213 100644 --- a/src/Redactions/NameRedaction.php +++ b/src/Redactions/NameRedaction.php @@ -7,6 +7,9 @@ use TinyBlocks\Logger\Internal\Redactor\Redactor; use TinyBlocks\Logger\Redaction; +/** + * Masks name field values, keeping a configurable number of leading characters visible. + */ final readonly class NameRedaction implements Redaction { private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 2; @@ -19,8 +22,10 @@ private function __construct(array $fields, int $visiblePrefixLength) fields: $fields, maskingFunction: static function (string $value) use ($visiblePrefixLength): string { $maskedLength = max(0, mb_strlen($value, 'UTF-8') - $visiblePrefixLength); + $template = '%s%s'; + return sprintf( - '%s%s', + $template, mb_substr($value, 0, $visiblePrefixLength, 'UTF-8'), str_repeat('*', $maskedLength) ); @@ -28,18 +33,30 @@ private function __construct(array $fields, int $visiblePrefixLength) ); } + /** + * Creates a NameRedaction from the fields to mask and the number of visible leading characters. + * + * @param string[] $fields The field names whose values are masked. + * @param int $visiblePrefixLength The number of leading characters left visible. + * @return NameRedaction The created instance. + */ public static function from(array $fields, int $visiblePrefixLength): NameRedaction { return new NameRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength); } + /** + * Builds a NameRedaction with the default name field and visible prefix length. + * + * @return NameRedaction The created instance. + */ public static function default(): NameRedaction { - return self::from(fields: ['name'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); + return NameRedaction::from(fields: ['name'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); } - public function redact(array $data): array + public function redact(array $payload): array { - return $this->redactor->redact(data: $data); + return $this->redactor->redact(payload: $payload); } } diff --git a/src/Redactions/PasswordRedaction.php b/src/Redactions/PasswordRedaction.php index 3ec2ba1..8bbbfc4 100644 --- a/src/Redactions/PasswordRedaction.php +++ b/src/Redactions/PasswordRedaction.php @@ -7,6 +7,9 @@ use TinyBlocks\Logger\Internal\Redactor\Redactor; use TinyBlocks\Logger\Redaction; +/** + * Masks password field values entirely with a fixed-length mask. + */ final readonly class PasswordRedaction implements Redaction { private const int DEFAULT_FIXED_MASK_LENGTH = 8; @@ -21,6 +24,13 @@ private function __construct(array $fields, int $fixedMaskLength) ); } + /** + * Creates a PasswordRedaction from the fields to mask and the fixed mask length. + * + * @param string[] $fields The field names whose values are masked. + * @param int $fixedMaskLength The fixed number of mask characters emitted. + * @return PasswordRedaction The created instance. + */ public static function from( array $fields, int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH @@ -28,13 +38,18 @@ public static function from( return new PasswordRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength); } + /** + * Builds a PasswordRedaction with the default password field and fixed mask length. + * + * @return PasswordRedaction The created instance. + */ public static function default(): PasswordRedaction { - return self::from(fields: ['password']); + return PasswordRedaction::from(fields: ['password']); } - public function redact(array $data): array + public function redact(array $payload): array { - return $this->redactor->redact(data: $data); + return $this->redactor->redact(payload: $payload); } } diff --git a/src/Redactions/PhoneRedaction.php b/src/Redactions/PhoneRedaction.php index 2f8164d..682c276 100644 --- a/src/Redactions/PhoneRedaction.php +++ b/src/Redactions/PhoneRedaction.php @@ -7,6 +7,9 @@ use TinyBlocks\Logger\Internal\Redactor\Redactor; use TinyBlocks\Logger\Redaction; +/** + * Masks phone field values, keeping a configurable number of trailing characters visible. + */ final readonly class PhoneRedaction implements Redaction { private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 4; @@ -20,8 +23,10 @@ private function __construct(array $fields, int $visibleSuffixLength) maskingFunction: static function (string $value) use ($visibleSuffixLength): string { $length = mb_strlen($value, 'UTF-8'); $maskedLength = max(0, $length - $visibleSuffixLength); + $template = '%s%s'; + return sprintf( - '%s%s', + $template, str_repeat('*', $maskedLength), mb_substr($value, -$visibleSuffixLength, null, 'UTF-8') ); @@ -29,18 +34,30 @@ private function __construct(array $fields, int $visibleSuffixLength) ); } + /** + * Creates a PhoneRedaction from the fields to mask and the number of visible trailing characters. + * + * @param string[] $fields The field names whose values are masked. + * @param int $visibleSuffixLength The number of trailing characters left visible. + * @return PhoneRedaction The created instance. + */ public static function from(array $fields, int $visibleSuffixLength): PhoneRedaction { return new PhoneRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); } + /** + * Builds a PhoneRedaction with the default phone field and visible suffix length. + * + * @return PhoneRedaction The created instance. + */ public static function default(): PhoneRedaction { - return self::from(fields: ['phone'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + return PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); } - public function redact(array $data): array + public function redact(array $payload): array { - return $this->redactor->redact(data: $data); + return $this->redactor->redact(payload: $payload); } } diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php index 293eeda..2d73482 100644 --- a/src/StructuredLogger.php +++ b/src/StructuredLogger.php @@ -6,10 +6,15 @@ use Psr\Log\LoggerTrait; use Stringable; +use TinyBlocks\Logger\Exceptions\UnknownLogLevel; use TinyBlocks\Logger\Internal\LogFormatter; +use TinyBlocks\Logger\Internal\LogLevel; use TinyBlocks\Logger\Internal\Redactor\Redactions; use TinyBlocks\Logger\Internal\Stream\LogStream; +/** + * Structured logger that writes redacted, formatted log entries to a stream. + */ final readonly class StructuredLogger implements Logger { use LoggerTrait; @@ -22,47 +27,74 @@ private function __construct( ) { } - public static function create(): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder(); - } - - public static function build( - LogStream $stream, + /** + * Creates a StructuredLogger from its stream, context, template, component, and redactions. + * + * @param mixed $stream The stream the logger writes to, or null to fall back to standard error. + * @param LogContext|null $context The correlation context, or null when none is bound. + * @param string $template The format template, or an empty string to use the default template. + * @param string $component The component name identifying the log source. + * @param Redaction ...$redactions The redaction strategies applied to context data before writing. + * @return StructuredLogger The created logger instance. + */ + public static function from( + mixed $stream, ?LogContext $context, - LogFormatter $formatter, - Redactions $redactions + string $template, + string $component, + Redaction ...$redactions ): StructuredLogger { + $formatter = $template === '' + ? LogFormatter::fromComponent(component: $component) + : LogFormatter::fromTemplate(template: $template, component: $component); + return new StructuredLogger( - stream: $stream, + stream: LogStream::from(resource: $stream), context: $context, formatter: $formatter, - redactions: $redactions + redactions: Redactions::createFrom(elements: $redactions) ); } - public function withContext(LogContext $context): static + /** + * Creates a StructuredLoggerBuilder. + * + * @return StructuredLoggerBuilder A new builder for configuring a StructuredLogger. + */ + public static function create(): StructuredLoggerBuilder { - return new StructuredLogger( - stream: $this->stream, - context: $context, - formatter: $this->formatter, - redactions: $this->redactions - ); + return new StructuredLoggerBuilder(); } - public function log($level, string|Stringable $message, array $context = []): void + public function log(mixed $level, string|Stringable $message, array $context = []): void { - $logLevel = LogLevel::from(strtoupper((string)$level)); - $redactedData = $this->redactions->applyTo(data: $context); + $logLevel = LogLevel::tryFrom(strtoupper((string)$level)); + + if (is_null($logLevel)) { + $template = 'Unknown log level: %s.'; + + throw new UnknownLogLevel(message: sprintf($template, (string)$level)); + } + + $redactedPayload = $this->redactions->applyTo(payload: $context); $formatted = $this->formatter->format( key: (string)$message, - data: $redactedData, level: $logLevel, - context: $this->context + context: $this->context, + payload: $redactedPayload ); $this->stream->write(content: $formatted); } + + public function withContext(LogContext $context): StructuredLogger + { + return new StructuredLogger( + stream: $this->stream, + context: $context, + formatter: $this->formatter, + redactions: $this->redactions + ); + } } diff --git a/src/StructuredLoggerBuilder.php b/src/StructuredLoggerBuilder.php index 537d451..18bf8e9 100644 --- a/src/StructuredLoggerBuilder.php +++ b/src/StructuredLoggerBuilder.php @@ -4,61 +4,118 @@ namespace TinyBlocks\Logger; -use TinyBlocks\Logger\Internal\LogFormatter; -use TinyBlocks\Logger\Internal\Redactor\Redactions; -use TinyBlocks\Logger\Internal\Stream\LogStream; - -final class StructuredLoggerBuilder +/** + * Fluent builder that assembles a StructuredLogger from a stream, context, template, component, and redactions. + */ +final readonly class StructuredLoggerBuilder { - private mixed $stream = null; - private ?LogContext $context = null; - private string $template = ''; - private string $component = ''; + public function __construct( + private mixed $stream = null, + private ?LogContext $context = null, + private string $template = '', + private string $component = '', + private array $redactions = [] + ) { + } - /** @var Redaction[] */ - private array $redactions = []; + /** + * Builds a StructuredLogger from the configured stream, context, template, component, and redactions. + * + * @return StructuredLogger The configured logger instance. + */ + public function build(): StructuredLogger + { + return StructuredLogger::from( + $this->stream, + $this->context, + $this->template, + $this->component, + ...$this->redactions + ); + } + /** + * Returns a copy of the builder with the stream replaced. + * + * @param mixed $stream The stream the logger writes to. + * @return StructuredLoggerBuilder A copy of the builder with the stream set. + */ public function withStream(mixed $stream): StructuredLoggerBuilder { - $this->stream = $stream; - return $this; + return new StructuredLoggerBuilder( + stream: $stream, + context: $this->context, + template: $this->template, + component: $this->component, + redactions: $this->redactions + ); } + /** + * Returns a copy of the builder with the context replaced. + * + * @param LogContext $context The context shared across log entries. + * @return StructuredLoggerBuilder A copy of the builder with the context set. + */ public function withContext(LogContext $context): StructuredLoggerBuilder { - $this->context = $context; - return $this; + return new StructuredLoggerBuilder( + stream: $this->stream, + context: $context, + template: $this->template, + component: $this->component, + redactions: $this->redactions + ); } + /** + * Returns a copy of the builder with the template replaced. + * + * @param string $template The format template for rendering entries. + * @return StructuredLoggerBuilder A copy of the builder with the template set. + */ public function withTemplate(string $template): StructuredLoggerBuilder { - $this->template = $template; - return $this; + return new StructuredLoggerBuilder( + stream: $this->stream, + context: $this->context, + template: $template, + component: $this->component, + redactions: $this->redactions + ); } + /** + * Returns a copy of the builder with the component replaced. + * + * @param string $component The component name identifying the log source. + * @return StructuredLoggerBuilder A copy of the builder with the component set. + */ public function withComponent(string $component): StructuredLoggerBuilder { - $this->component = $component; - return $this; + return new StructuredLoggerBuilder( + stream: $this->stream, + context: $this->context, + template: $this->template, + component: $component, + redactions: $this->redactions + ); } + /** + * Returns a copy of the builder with the given redactions appended. + * + * @param Redaction ...$redactions The redaction strategies to apply to log data. + * @return StructuredLoggerBuilder A copy of the builder with the redactions appended. + */ public function withRedactions(Redaction ...$redactions): StructuredLoggerBuilder { - $this->redactions = array_merge($this->redactions, $redactions); - return $this; - } - - public function build(): StructuredLogger - { - $formatter = empty($this->template) - ? LogFormatter::fromComponent(component: $this->component) - : LogFormatter::fromTemplate(component: $this->component, template: $this->template); - - return StructuredLogger::build( - stream: LogStream::from(resource: $this->stream), + return new StructuredLoggerBuilder( + stream: $this->stream, context: $this->context, - formatter: $formatter, - redactions: Redactions::createFrom(elements: $this->redactions) + template: $this->template, + component: $this->component, + redactions: array_merge($this->redactions, $redactions) ); } } diff --git a/tests/InMemoryStream.php b/tests/InMemoryStream.php new file mode 100644 index 0000000..4467687 --- /dev/null +++ b/tests/InMemoryStream.php @@ -0,0 +1,44 @@ +resource; + + if (is_resource($resource)) { + fclose($resource); + } + } + + public function handle(): mixed + { + return $this->resource; + } + + public function contents(): string + { + $resource = $this->resource; + + if (!is_resource($resource)) { + return ''; + } + + rewind($resource); + + return (string)stream_get_contents($resource); + } +} diff --git a/tests/Internal/Stream/LogStreamTest.php b/tests/Internal/Stream/LogStreamTest.php deleted file mode 100644 index f6d8e05..0000000 --- a/tests/Internal/Stream/LogStreamTest.php +++ /dev/null @@ -1,22 +0,0 @@ -stream = fopen('php://memory', 'r+'); + $this->logStream = InMemoryStream::create(); } protected function tearDown(): void { - if (is_resource($this->stream)) { - fclose($this->stream); - } + $this->logStream->close(); } - public function testLogInfo(): void + public function testLogWhenDebugEntryThenWritesDebugLevel(): void { /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'account-service') - ->build(); - - /** @When logging an info entry */ - $logger->info(message: 'account.created', context: ['accountId' => 1]); - - /** @Then the output should contain the expected level, component, key, and data */ - $output = $this->streamContents(); - - self::assertStringContainsString('component=account-service', $output); - self::assertStringContainsString('level=INFO', $output); - self::assertStringContainsString('key=account.created', $output); - self::assertStringContainsString('"accountId":1', $output); - } - - public function testLogWarning(): void - { - /** @Given a structured logger */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'inventory-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'debug-service') ->build(); - /** @When logging a warning entry */ - $logger->warning(message: 'stock.low', context: ['productId' => 7, 'remaining' => 2]); + /** @When logging a debug entry */ + $logger->debug(message: 'query.executed', context: ['sql' => 'SELECT 1']); - /** @Then the output should contain the warning level */ - $output = $this->streamContents(); + /** @Then the output should contain the debug level */ + $output = $this->logStream->contents(); - self::assertStringContainsString('level=WARNING', $output); - self::assertStringContainsString('key=stock.low', $output); + self::assertStringContainsString('level=DEBUG', $output); + self::assertStringContainsString('key=query.executed', $output); } - public function testLogError(): void + public function testLogWhenErrorEntryThenWritesErrorLevel(): void { /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'payment-service') ->build(); @@ -80,80 +60,56 @@ public function testLogError(): void $logger->error(message: 'payment.failed', context: ['reason' => 'timeout']); /** @Then the output should contain the error level */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); self::assertStringContainsString('level=ERROR', $output); self::assertStringContainsString('key=payment.failed', $output); } - public function testLogDebug(): void + public function testRedactWhenShortPasswordThenMasksFully(): void { - /** @Given a structured logger */ + /** @Given a structured logger with password redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'debug-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::default()) ->build(); - /** @When logging a debug entry */ - $logger->debug(message: 'query.executed', context: ['sql' => 'SELECT 1']); - - /** @Then the output should contain the debug level */ - $output = $this->streamContents(); - - self::assertStringContainsString('level=DEBUG', $output); - self::assertStringContainsString('key=query.executed', $output); - } - - public function testLogWithEmptyData(): void - { - /** @Given a structured logger */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'minimal-service') - ->build(); + /** @When logging with a short password */ + $logger->info(message: 'login.attempt', context: ['password' => 'ab']); - /** @When logging with no data */ - $logger->info(message: 'heartbeat'); + /** @Then the password should still be fully masked */ + $output = $this->logStream->contents(); - /** @Then the output should contain an empty JSON array for data */ - self::assertStringContainsString('data=[]', $this->streamContents()); + self::assertStringContainsString('********', $output); + self::assertStringNotContainsString('"password":"ab"', $output); } - public function testLogReplacesUnencodableDataWithEncodingFailurePayload(): void + public function testLogWhenInfoEntryThenWritesLevelAndData(): void { /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'broken-json-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'account-service') ->build(); - /** @When logging a payload that json_encode cannot serialize */ - $logger->info(message: 'bad.payload', context: ['value' => "\xB1\x31"]); - - /** @Then the data section should contain the encoding failure payload */ - self::assertStringContainsString('data={"error":"encoding_failed"}', $this->streamContents()); - } - - public function testLogEscapesControlCharactersInMessageKey(): void - { - /** @Given a structured logger */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'injection-test') - ->build(); + /** @When logging an info entry */ + $logger->info(message: 'account.created', context: ['accountId' => 1]); - /** @When logging with a message key that contains a newline */ - $logger->info(message: "safe\nkey"); + /** @Then the output should contain the expected level, component, key, and data */ + $output = $this->logStream->contents(); - /** @Then newline characters should appear escaped in the log line */ - self::assertStringContainsString('key=safe\\nkey', $this->streamContents()); + self::assertStringContainsString('component=account-service', $output); + self::assertStringContainsString('level=INFO', $output); + self::assertStringContainsString('key=account.created', $output); + self::assertStringContainsString('"accountId":1', $output); } - public function testLogWithoutContextHasEmptyCorrelationId(): void + public function testLogWhenNoContextThenCorrelationIdIsEmpty(): void { /** @Given a structured logger without any context */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'no-context-service') ->build(); @@ -161,196 +117,163 @@ public function testLogWithoutContextHasEmptyCorrelationId(): void $logger->info(message: 'no.context.event'); /** @Then the correlation ID should be empty between the markers */ - $output = $this->streamContents(); - - self::assertMatchesRegularExpression('/correlation_id= level=/', $output); + self::assertMatchesRegularExpression('/correlation_id= level=/', $this->logStream->contents()); } - public function testLogPreservesSlashesAndUnicodeInData(): void + public function testLogWhenNoPayloadThenWritesEmptyDataArray(): void { /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'encoding-service') - ->build(); - - /** @When logging with data containing slashes and Unicode characters */ - $logger->info(message: 'path.resolved', context: [ - 'url' => 'https://example.com/api/v1/users', - 'name' => 'José María' - ]); - - /** @Then the slashes should not be escaped */ - $output = $this->streamContents(); - - self::assertStringContainsString('https://example.com/api/v1/users', $output); - self::assertStringNotContainsString('https:\/\/example.com\/api\/v1\/users', $output); - - /** @And the Unicode characters should not be escaped */ - self::assertStringContainsString('José María', $output); - self::assertStringNotContainsString('\u00e9', $output); - } - - public function testLogWithCorrelationId(): void - { - /** @Given a structured logger with a correlation context derived after creation */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'order-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'minimal-service') ->build(); - $loggerWithContext = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); - - /** @When logging from the contextual logger */ - $loggerWithContext->info(message: 'order.placed', context: ['orderId' => 42]); + /** @When logging with no data */ + $logger->info(message: 'heartbeat'); - /** @Then the output should contain the correlation ID */ - self::assertStringContainsString('correlation_id=req-abc-123', $this->streamContents()); + /** @Then the output should contain an empty JSON array for data */ + self::assertStringContainsString('data=[]', $this->logStream->contents()); } - public function testLogWithCorrelationIdFromCreation(): void + public function testRedactWhenNameFieldThenMasksAllButPrefix(): void { - /** @Given a structured logger created with a correlation context */ + /** @Given a structured logger with name redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withContext(context: LogContext::from(correlationId: 'req-initial')) - ->withComponent(component: 'order-service') - ->build(); - - /** @When logging */ - $logger->info(message: 'order.started'); - - /** @Then the output should contain the correlation ID */ - self::assertStringContainsString('correlation_id=req-initial', $this->streamContents()); - } - - public function testWithContextReturnsNewInstanceWithoutMutatingOriginal(): void - { - /** @Given a structured logger and a contextual copy */ - $original = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::default()) ->build(); - $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999')); - - /** @When logging from both instances */ - $original->info(message: 'auth.check'); - $contextual->info(message: 'auth.success'); + /** @When logging with a name field */ + $logger->info(message: 'user.created', context: ['name' => 'Gustavo', 'role' => 'admin']); - /** @Then the original log should not contain the correlation ID and the contextual one should */ - $lines = array_filter(explode("\n", $this->streamContents())); + /** @Then the name should be redacted preserving only the first 2 characters */ + $output = $this->logStream->contents(); - self::assertStringNotContainsString('correlation_id=ctx-999', $lines[0]); - self::assertStringContainsString('correlation_id=ctx-999', $lines[1]); + self::assertStringContainsString('Gu*****', $output); + self::assertStringNotContainsString('"name":"Gustavo"', $output); + self::assertStringContainsString('admin', $output); } - public function testLogWithDocumentRedaction(): void + public function testLogWhenWarningEntryThenWritesWarningLevel(): void { - /** @Given a structured logger with document redaction */ + /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'payment-service') - ->withRedactions(DocumentRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'inventory-service') ->build(); - /** @When logging with a document field */ - $logger->error(message: 'payment.failed', context: ['document' => '12345678900', 'amount' => 100.50]); + /** @When logging a warning entry */ + $logger->warning(message: 'stock.low', context: ['productId' => 7, 'remaining' => 2]); - /** @Then the document should be redacted showing only the last 3 characters */ - $output = $this->streamContents(); + /** @Then the output should contain the warning level */ + $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); - self::assertStringNotContainsString('12345678900', $output); - self::assertStringContainsString('100.5', $output); + self::assertStringContainsString('level=WARNING', $output); + self::assertStringContainsString('key=stock.low', $output); } - public function testLogWithDocumentRedactionOnMultipleFields(): void + public function testRedactWhenMultipleNameFieldsThenMasksEach(): void { - /** @Given a structured logger with document redaction targeting multiple field names */ + /** @Given a structured logger with name redaction targeting multiple field names */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions( + NameRedaction::from( + fields: ['name', 'full_name', 'firstName'], + visiblePrefixLength: 3 + ) + ) ->build(); - /** @When logging with both fields */ - $logger->info(message: 'kyc.verified', context: [ - 'cpf' => '12345678900', - 'cnpj' => '12345678000199' + /** @When logging with multiple name field variations */ + $logger->info(message: 'user.updated', context: [ + 'name' => 'Gustavo', + 'full_name' => 'Gustavo Freze', + 'firstName' => 'Maria' ]); - /** @Then both fields should be redacted showing only the last 5 characters */ - $output = $this->streamContents(); + /** @Then all name fields should be redacted showing only the first 3 characters */ + $output = $this->logStream->contents(); - self::assertStringContainsString('******78900', $output); - self::assertStringContainsString('*********00199', $output); - self::assertStringNotContainsString('12345678900', $output); - self::assertStringNotContainsString('12345678000199', $output); + self::assertStringContainsString('Gus****', $output); + self::assertStringContainsString('Gus**********', $output); + self::assertStringContainsString('Mar**', $output); + self::assertStringNotContainsString('"name":"Gustavo"', $output); + self::assertStringNotContainsString('"full_name":"Gustavo Freze"', $output); + self::assertStringNotContainsString('"firstName":"Maria"', $output); } - public function testLogWithDocumentRedactionWhenValueIsShorterThanVisibleLength(): void + public function testRedactWhenPhoneFieldThenMasksAllButSuffix(): void { - /** @Given a structured logger with document redaction configured to show 10 characters */ + /** @Given a structured logger with phone redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 10)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::default()) ->build(); - /** @When logging with a document shorter than the visible length */ - $logger->info(message: 'kyc.check', context: ['document' => 'abc']); + /** @When logging with a phone field */ + $logger->info(message: 'sms.sent', context: ['phone' => '+5511999887766']); - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + /** @Then the phone should be redacted showing only the last 4 characters */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"document":"abc"', $output); - self::assertStringNotContainsString('*', $output); + self::assertStringContainsString('**********7766', $output); + self::assertStringNotContainsString('+5511999887766', $output); } - public function testLogWithDocumentRedactionExactLengthMatch(): void + public function testRedactWhenSeveralRulesThenAppliesEachRule(): void { - /** @Given a structured logger with document redaction where visible length equals value length */ + /** @Given a structured logger with multiple redactions */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions( + DocumentRedaction::default(), + EmailRedaction::default(), + PhoneRedaction::default() + ) ->build(); - /** @When logging with a document whose length equals the visible suffix length */ - $logger->info(message: 'kyc.check', context: ['document' => 'abc']); + /** @When logging with multiple sensitive fields */ + $logger->info(message: 'user.registered', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com', + 'phone' => '+5511999887766', + 'name' => 'John' + ]); - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + /** @Then each field should be redacted according to its rule */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"document":"abc"', $output); - self::assertStringNotContainsString('*', $output); + self::assertStringContainsString('********900', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringContainsString('**********7766', $output); + self::assertStringContainsString('John', $output); } - public function testLogWithEmailRedaction(): void + public function testLogWhenNoRedactionThenLeavesDataUnmodified(): void { - /** @Given a structured logger with email redaction */ + /** @Given a structured logger without any redactions */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'simple-service') ->build(); - /** @When logging with an email field */ - $logger->info(message: 'user.registered', context: ['email' => 'john@example.com']); - - /** @Then the email should be redacted preserving only the first 2 characters of the local part */ - $output = $this->streamContents(); + /** @When logging with data that could be sensitive */ + $logger->info(message: 'data.processed', context: ['document' => '12345678900']); - self::assertStringContainsString('jo**@example.com', $output); - self::assertStringNotContainsString('john@example.com', $output); + /** @Then the data should appear unmodified */ + self::assertStringContainsString('12345678900', $this->logStream->contents()); } - public function testLogWithEmailRedactionOnMultipleFields(): void + public function testRedactWhenMultipleEmailFieldsThenMasksEach(): void { /** @Given a structured logger with email redaction targeting multiple field names */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'notification-service') ->withRedactions( EmailRedaction::from( @@ -368,162 +291,158 @@ public function testLogWithEmailRedactionOnMultipleFields(): void ]); /** @Then all email fields should be redacted */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); self::assertStringContainsString('jo**@example.com', $output); self::assertStringContainsString('ja**@corp.io', $output); self::assertStringContainsString('ad***@recovery.org', $output); } - public function testLogWithEmailRedactionWithoutAtSign(): void + public function testRedactWhenMultiplePhoneFieldsThenMasksEach(): void { - /** @Given a structured logger with email redaction */ + /** @Given a structured logger with phone redaction targeting multiple field names */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'contact-service') + ->withRedactions( + PhoneRedaction::from( + fields: ['phone', 'mobile', 'whatsapp'], + visibleSuffixLength: 4 + ) + ) ->build(); - /** @When logging with a multibyte value missing an @ sign */ - $logger->info(message: 'user.attempt', context: ['email' => 'çãoabc']); + /** @When logging with multiple phone field variations */ + $logger->info(message: 'contact.updated', context: [ + 'phone' => '+5511999887766', + 'mobile' => '+5521988776655', + 'whatsapp' => '+5531977665544' + ]); - /** @Then the mask length matches the number of characters, not bytes */ - $output = $this->streamContents(); + /** @Then all phone fields should be redacted */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"email":"******"', $output); - self::assertStringNotContainsString('çãoabc', $output); + self::assertStringContainsString('**********7766', $output); + self::assertStringContainsString('**********6655', $output); + self::assertStringContainsString('**********5544', $output); } - public function testLogWithEmailRedactionWhenLocalPartIsShorterThanVisibleLength(): void + public function testLogWhenCustomTemplateThenFollowsCustomFormat(): void { - /** @Given a structured logger with email redaction configured to show 10 characters */ + /** @Given a structured logger with a custom template */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 10)) + ->withStream(stream: $this->logStream->handle()) + ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") + ->withComponent(component: 'custom-service') ->build(); - /** @When logging with an email whose local part is shorter than the visible length */ - $logger->info(message: 'user.check', context: ['email' => 'ab@test.com']); + /** @When logging an info entry */ + $logger->info(message: 'custom.event', context: ['value' => 42]); - /** @Then the email should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + /** @Then the output should follow the custom format */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"email":"ab@test.com"', $output); - self::assertStringNotContainsString('*', $output); + self::assertStringContainsString('custom-service', $output); + self::assertStringContainsString('custom.event', $output); + self::assertStringContainsString('"value":42', $output); + self::assertStringNotContainsString('component=', $output); } - public function testLogWithPhoneRedaction(): void + public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void { - /** @Given a structured logger with phone redaction */ + /** @Given a structured logger with document redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'notification-service') - ->withRedactions(PhoneRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(DocumentRedaction::default()) ->build(); - /** @When logging with a phone field */ - $logger->info(message: 'sms.sent', context: ['phone' => '+5511999887766']); + /** @When logging with a document field */ + $logger->error(message: 'payment.failed', context: ['document' => '12345678900', 'amount' => 100.50]); - /** @Then the phone should be redacted showing only the last 4 characters */ - $output = $this->streamContents(); + /** @Then the document should be redacted showing only the last 3 characters */ + $output = $this->logStream->contents(); - self::assertStringContainsString('**********7766', $output); - self::assertStringNotContainsString('+5511999887766', $output); + self::assertStringContainsString('********900', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringContainsString('100.5', $output); } - public function testLogWithPhoneRedactionOnMultipleFields(): void + public function testRedactWhenEmailFieldThenMasksLocalPartSuffix(): void { - /** @Given a structured logger with phone redaction targeting multiple field names */ + /** @Given a structured logger with email redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'contact-service') - ->withRedactions( - PhoneRedaction::from( - fields: ['phone', 'mobile', 'whatsapp'], - visibleSuffixLength: 4 - ) - ) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::default()) ->build(); - /** @When logging with multiple phone field variations */ - $logger->info(message: 'contact.updated', context: [ - 'phone' => '+5511999887766', - 'mobile' => '+5521988776655', - 'whatsapp' => '+5531977665544' - ]); + /** @When logging with an email field */ + $logger->info(message: 'user.registered', context: ['email' => 'john@example.com']); - /** @Then all phone fields should be redacted */ - $output = $this->streamContents(); + /** @Then the email should be redacted preserving only the first 2 characters of the local part */ + $output = $this->logStream->contents(); - self::assertStringContainsString('**********7766', $output); - self::assertStringContainsString('**********6655', $output); - self::assertStringContainsString('**********5544', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringNotContainsString('john@example.com', $output); } - public function testLogWithPhoneRedactionWhenValueIsShorterThanVisibleLength(): void + public function testRedactWhenNestedNameThenMasksAndKeepsSibling(): void { - /** @Given a structured logger with phone redaction configured to show 10 characters */ + /** @Given a structured logger with name redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'notification-service') - ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 10)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::default()) ->build(); - /** @When logging with a phone shorter than the visible length */ - $logger->info(message: 'sms.check', context: ['phone' => '1234']); - - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); - - self::assertStringContainsString('"phone":"1234"', $output); - self::assertStringNotContainsString('*', $output); - } - - public function testLogWithPhoneRedactionExactLengthMatch(): void - { - /** @Given a structured logger with phone redaction where visible length equals value length */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'notification-service') - ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4)) - ->build(); + /** @When logging with a nested structure containing a name field */ + $logger->info(message: 'profile.loaded', context: [ + 'profile' => [ + 'name' => 'Gustavo', + 'email' => 'gustavo@example.com' + ] + ]); - /** @When logging with a phone whose length equals the visible suffix length */ - $logger->info(message: 'sms.check', context: ['phone' => '1234']); + /** @Then the nested name should be redacted */ + $output = $this->logStream->contents(); - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + self::assertStringContainsString('Gu*****', $output); + self::assertStringNotContainsString('"name":"Gustavo"', $output); - self::assertStringContainsString('"phone":"1234"', $output); - self::assertStringNotContainsString('*', $output); + /** @And the sibling field should be preserved */ + self::assertStringContainsString('"email":"gustavo@example.com"', $output); } - public function testLogWithPasswordRedaction(): void + public function testRedactWhenMultipleDocumentFieldsThenMasksEach(): void { - /** @Given a structured logger with password redaction */ + /** @Given a structured logger with document redaction targeting multiple field names */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5)) ->build(); - /** @When logging with a password field */ - $logger->info(message: 'login.attempt', context: ['password' => 's3cr3t!', 'username' => 'john']); + /** @When logging with both fields */ + $logger->info(message: 'kyc.verified', context: [ + 'cpf' => '12345678900', + 'cnpj' => '12345678000199' + ]); - /** @Then the password should be fully masked */ - $output = $this->streamContents(); + /** @Then both fields should be redacted showing only the last 5 characters */ + $output = $this->logStream->contents(); - self::assertStringContainsString('*******', $output); - self::assertStringNotContainsString('s3cr3t!', $output); - self::assertStringContainsString('john', $output); + self::assertStringContainsString('******78900', $output); + self::assertStringContainsString('*********00199', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringNotContainsString('12345678000199', $output); } - public function testLogWithPasswordRedactionOnMultipleFields(): void + public function testRedactWhenMultiplePasswordFieldsThenMasksEach(): void { /** @Given a structured logger with password redaction targeting multiple field names */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') ->withRedactions(PasswordRedaction::from(fields: ['password', 'secret', 'token'])) ->build(); @@ -536,353 +455,332 @@ public function testLogWithPasswordRedactionOnMultipleFields(): void ]); /** @Then all password fields should be fully masked */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); self::assertStringNotContainsString('myP@ssw0rd', $output); self::assertStringNotContainsString('hidden-value', $output); self::assertStringNotContainsString('abc123xyz', $output); } - public function testLogWithPasswordRedactionOnShortValue(): void + public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void { - /** @Given a structured logger with password redaction */ + /** @Given a structured logger built with redactions added in separate calls */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'multi-call-service') + ->withRedactions(DocumentRedaction::default()) + ->withRedactions(EmailRedaction::default()) ->build(); - /** @When logging with a short password */ - $logger->info(message: 'login.attempt', context: ['password' => 'ab']); + /** @When logging with both sensitive fields */ + $logger->info(message: 'multi.call', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com' + ]); - /** @Then the password should still be fully masked */ - $output = $this->streamContents(); + /** @Then both fields should be redacted */ + $output = $this->logStream->contents(); - self::assertStringContainsString('********', $output); - self::assertStringNotContainsString('"password":"ab"', $output); + self::assertStringContainsString('********900', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringNotContainsString('john@example.com', $output); } - public function testLogWithPasswordRedactionOnNestedField(): void + public function testLogWhenDefaultTemplateThenFollowsDefaultFormat(): void { - /** @Given a structured logger with password redaction */ + /** @Given a structured logger using the default template */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'default-service') ->build(); - /** @When logging with a nested structure containing a password field */ - $logger->info(message: 'credentials.received', context: [ - 'credentials' => [ - 'password' => 'sup3rS3cret', - 'username' => 'admin' - ] - ]); + /** @When logging an info entry */ + $logger->info(message: 'default.event'); - /** @Then the nested password should be fully masked */ - $output = $this->streamContents(); + /** @Then the output should use the default template format */ + $output = $this->logStream->contents(); - self::assertStringNotContainsString('sup3rS3cret', $output); - self::assertStringContainsString('"username":"admin"', $output); + self::assertStringContainsString('component=default-service', $output); + self::assertStringContainsString('level=INFO', $output); + self::assertStringContainsString('key=default.event', $output); + self::assertStringContainsString('correlation_id=', $output); } + public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void + { + /** @Given a structured logger */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'level-service') + ->build(); + + /** @Then logging at an unsupported level raises an unknown log level error */ + $this->expectException(UnknownLogLevel::class); + + /** @When logging at a level outside the supported set */ + $logger->log('not-a-level', 'some.key'); + } - public function testLogWithPasswordRedactionDoesNotRevealValueLength(): void + public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void { - /** @Given a structured logger with password redaction */ + /** @Given a structured logger with all available redactions */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'full-service') + ->withRedactions( + DocumentRedaction::default(), + EmailRedaction::default(), + PhoneRedaction::default(), + PasswordRedaction::default(), + NameRedaction::default() + ) ->build(); - /** @When logging passwords of different lengths */ - $logger->info(message: 'login.short', context: ['password' => '123']); - $logger->info(message: 'login.long', context: ['password' => 'mySuperLongP@ssw0rd!123']); + /** @When logging with all sensitive fields */ + $logger->info(message: 'user.full.register', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com', + 'phone' => '+5511999887766', + 'password' => 's3cr3t!', + 'name' => 'John', + 'status' => 'active' + ]); - /** @Then both should produce the same fixed-length mask */ - $lines = array_filter(explode("\n", $this->streamContents())); + /** @Then each field should be redacted according to its rule */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"password":"********"', $lines[0]); - self::assertStringContainsString('"password":"********"', $lines[1]); + self::assertStringContainsString('********900', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringContainsString('**********7766', $output); + self::assertStringNotContainsString('s3cr3t!', $output); + self::assertStringContainsString('Jo**', $output); + self::assertStringNotContainsString('"name":"John"', $output); + self::assertStringContainsString('active', $output); } - public function testLogWithPasswordRedactionWithCustomFixedMaskLength(): void + public function testLogWhenKeyHasNewlineThenEscapesControlCharacter(): void { - /** @Given a structured logger with password redaction configured with a custom fixed mask length */ + /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::from(fields: ['password'], fixedMaskLength: 12)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'injection-test') ->build(); - /** @When logging with a password field */ - $logger->info(message: 'login.attempt', context: ['password' => 'abc']); - - /** @Then the mask should have exactly 12 asterisks */ - $output = $this->streamContents(); + /** @When logging with a message key that contains a newline */ + $logger->info(message: "safe\nkey"); - self::assertStringContainsString('"password":"************"', $output); - self::assertStringNotContainsString('abc', $output); + /** @Then newline characters should appear escaped in the log line */ + self::assertStringContainsString('key=safe\\nkey', $this->logStream->contents()); } - public function testLogWithNameRedaction(): void + public function testRedactWhenPasswordFieldThenMasksAndKeepsSibling(): void { - /** @Given a structured logger with name redaction */ + /** @Given a structured logger with password redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(NameRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::default()) ->build(); - /** @When logging with a name field */ - $logger->info(message: 'user.created', context: ['name' => 'Gustavo', 'role' => 'admin']); + /** @When logging with a password field */ + $logger->info(message: 'login.attempt', context: ['password' => 's3cr3t!', 'username' => 'john']); - /** @Then the name should be redacted preserving only the first 2 characters */ - $output = $this->streamContents(); + /** @Then the password should be fully masked */ + $output = $this->logStream->contents(); - self::assertStringContainsString('Gu*****', $output); - self::assertStringNotContainsString('"name":"Gustavo"', $output); - self::assertStringContainsString('admin', $output); + self::assertStringContainsString('*******', $output); + self::assertStringNotContainsString('s3cr3t!', $output); + self::assertStringContainsString('john', $output); } - public function testLogWithNameRedactionOnMultipleFields(): void + public function testRedactWhenNestedPasswordThenMasksAndKeepsSibling(): void { - /** @Given a structured logger with name redaction targeting multiple field names */ + /** @Given a structured logger with password redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions( - NameRedaction::from( - fields: ['name', 'full_name', 'firstName'], - visiblePrefixLength: 3 - ) - ) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::default()) ->build(); - /** @When logging with multiple name field variations */ - $logger->info(message: 'user.updated', context: [ - 'name' => 'Gustavo', - 'full_name' => 'Gustavo Freze', - 'firstName' => 'Maria' + /** @When logging with a nested structure containing a password field */ + $logger->info(message: 'credentials.received', context: [ + 'credentials' => [ + 'password' => 'sup3rS3cret', + 'username' => 'admin' + ] ]); - /** @Then all name fields should be redacted showing only the first 3 characters */ - $output = $this->streamContents(); + /** @Then the nested password should be fully masked */ + $output = $this->logStream->contents(); - self::assertStringContainsString('Gus****', $output); - self::assertStringContainsString('Gus**********', $output); - self::assertStringContainsString('Mar**', $output); - self::assertStringNotContainsString('"name":"Gustavo"', $output); - self::assertStringNotContainsString('"full_name":"Gustavo Freze"', $output); - self::assertStringNotContainsString('"firstName":"Maria"', $output); + self::assertStringNotContainsString('sup3rS3cret', $output); + self::assertStringContainsString('"username":"admin"', $output); } - public function testLogWithNameRedactionWhenValueIsShorterThanVisibleLength(): void + public function testBuildWhenNoStreamConfiguredThenUsesStderrFallback(): void { - /** @Given a structured logger with name redaction configured to show 10 characters */ + /** @Given a logger builder without an explicit stream */ + $builder = StructuredLogger::create()->withComponent(component: 'stderr-service'); + + /** @When building the logger so the stream falls back to standard error */ + $logger = $builder->build(); + + /** @Then the built logger implements the PSR-3 logger contract */ + self::assertInstanceOf(Logger::class, $logger); + } + + public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void + { + /** @Given a structured logger with document redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 10)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'multi-level-service') + ->withRedactions(DocumentRedaction::default()) ->build(); - /** @When logging with a name shorter than the visible length */ - $logger->info(message: 'user.check', context: ['name' => 'Ana']); + /** @When logging with a nested array followed by a scalar field that both require redaction */ + $logger->info(message: 'multi.level', context: [ + 'nested' => ['document' => '11111111100'], + 'document' => '99999999900' + ]); - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + /** @Then both the nested and scalar documents should be redacted */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"name":"Ana"', $output); - self::assertStringNotContainsString('*', $output); + self::assertStringContainsString('********100', $output); + self::assertStringContainsString('********900', $output); + self::assertStringNotContainsString('11111111100', $output); + self::assertStringNotContainsString('99999999900', $output); } - public function testLogWithNameRedactionExactLengthMatch(): void + public function testRedactWhenCustomMaskLengthThenMaskMatchesThatWidth(): void { - /** @Given a structured logger with name redaction where visible length equals value length */ + /** @Given a structured logger with password redaction configured with a custom fixed mask length */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 3)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::from(fields: ['password'], fixedMaskLength: 12)) ->build(); - /** @When logging with a name whose length equals the visible prefix length */ - $logger->info(message: 'user.check', context: ['name' => 'Ana']); + /** @When logging with a password field */ + $logger->info(message: 'login.attempt', context: ['password' => 'abc']); - /** @Then the value should remain exactly as-is with no masking asterisks */ - $output = $this->streamContents(); + /** @Then the mask should have exactly 12 asterisks */ + $output = $this->logStream->contents(); - self::assertStringContainsString('"name":"Ana"', $output); - self::assertStringNotContainsString('*', $output); + self::assertStringContainsString('"password":"************"', $output); + self::assertStringNotContainsString('abc', $output); } - public function testLogWithNameRedactionOnNestedField(): void + public function testLogWhenContextDerivedThenOriginalOmitsCorrelationId(): void { - /** @Given a structured logger with name redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(NameRedaction::default()) + /** @Given a structured logger */ + $original = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') ->build(); - /** @When logging with a nested structure containing a name field */ - $logger->info(message: 'profile.loaded', context: [ - 'profile' => [ - 'name' => 'Gustavo', - 'email' => 'gustavo@example.com' - ] - ]); + /** @And a contextual logger derived from it */ + $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999')); - /** @Then the nested name should be redacted */ - $output = $this->streamContents(); + /** @When logging from the original instance */ + $original->info(message: 'auth.check'); - self::assertStringContainsString('Gu*****', $output); - self::assertStringNotContainsString('"name":"Gustavo"', $output); + /** @Then the original line does not carry the correlation ID */ + self::assertStringNotContainsString('correlation_id=ctx-999', $this->logStream->contents()); - /** @And the sibling field should be preserved */ - self::assertStringContainsString('"email":"gustavo@example.com"', $output); + /** @And deriving a context yields a new instance, leaving the original untouched */ + self::assertNotSame($original, $contextual); } - public function testLogWithMultipleRedactions(): void + public function testRedactWhenEmailHasNoAtSignThenMasksByCharacterCount(): void { - /** @Given a structured logger with multiple redactions */ + /** @Given a structured logger with email redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') - ->withRedactions( - DocumentRedaction::default(), - EmailRedaction::default(), - PhoneRedaction::default() - ) + ->withRedactions(EmailRedaction::default()) ->build(); - /** @When logging with multiple sensitive fields */ - $logger->info(message: 'user.registered', context: [ - 'document' => '12345678900', - 'email' => 'john@example.com', - 'phone' => '+5511999887766', - 'name' => 'John' - ]); + /** @When logging with a multibyte value missing an @ sign */ + $logger->info(message: 'user.attempt', context: ['email' => 'çãoabc']); - /** @Then each field should be redacted according to its rule */ - $output = $this->streamContents(); + /** @Then the mask length matches the number of characters, not bytes */ + $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); - self::assertStringContainsString('jo**@example.com', $output); - self::assertStringContainsString('**********7766', $output); - self::assertStringContainsString('John', $output); + self::assertStringContainsString('"email":"******"', $output); + self::assertStringNotContainsString('çãoabc', $output); } - public function testLogWithAllRedactions(): void + public function testRedactWhenNameIsMultibyteThenPrefixCountsCharacters(): void { - /** @Given a structured logger with all available redactions */ + /** @Given a structured logger with name redaction and a multibyte value */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'full-service') - ->withRedactions( - DocumentRedaction::default(), - EmailRedaction::default(), - PhoneRedaction::default(), - PasswordRedaction::default(), - NameRedaction::default() - ) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'profile-service') + ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 2)) ->build(); - /** @When logging with all sensitive fields */ - $logger->info(message: 'user.full.register', context: [ - 'document' => '12345678900', - 'email' => 'john@example.com', - 'phone' => '+5511999887766', - 'password' => 's3cr3t!', - 'name' => 'John', - 'status' => 'active' - ]); - - /** @Then each field should be redacted according to its rule */ - $output = $this->streamContents(); + /** @When logging with a name that contains a multibyte character */ + $logger->info(message: 'profile.viewed', context: ['name' => 'Ümit']); - self::assertStringContainsString('********900', $output); - self::assertStringContainsString('jo**@example.com', $output); - self::assertStringContainsString('**********7766', $output); - self::assertStringNotContainsString('s3cr3t!', $output); - self::assertStringContainsString('Jo**', $output); - self::assertStringNotContainsString('"name":"John"', $output); - self::assertStringContainsString('active', $output); + /** @Then the visible prefix should contain whole characters, not bytes */ + self::assertStringContainsString('"name":"Üm**"', $this->logStream->contents()); } - public function testLogWithoutRedaction(): void + public function testLogWhenContextBoundAtCreationThenWritesCorrelationId(): void { - /** @Given a structured logger without any redactions */ + /** @Given a structured logger created with a correlation context */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'simple-service') + ->withStream(stream: $this->logStream->handle()) + ->withContext(context: LogContext::from(correlationId: 'req-initial')) + ->withComponent(component: 'order-service') ->build(); - /** @When logging with data that could be sensitive */ - $logger->info(message: 'data.processed', context: ['document' => '12345678900']); + /** @When logging */ + $logger->info(message: 'order.started'); - /** @Then the data should appear unmodified */ - self::assertStringContainsString('12345678900', $this->streamContents()); + /** @Then the output should contain the correlation ID */ + self::assertStringContainsString('correlation_id=req-initial', $this->logStream->contents()); } - public function testLogRedactsNestedArrayAndScalarFieldsInSameLevel(): void + public function testLogWhenPayloadIsUnencodableThenWritesEncodingFailure(): void { - /** @Given a structured logger with document redaction */ + /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'multi-level-service') - ->withRedactions(DocumentRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'broken-json-service') ->build(); - /** @When logging with a nested array followed by a scalar field that both require redaction */ - $logger->info(message: 'multi.level', context: [ - 'nested' => ['document' => '11111111100'], - 'document' => '99999999900' - ]); - - /** @Then both the nested and scalar documents should be redacted */ - $output = $this->streamContents(); + /** @When logging a payload that json_encode cannot serialize */ + $logger->info(message: 'bad.payload', context: ['value' => "\xB1\x31"]); - self::assertStringContainsString('********100', $output); - self::assertStringContainsString('********900', $output); - self::assertStringNotContainsString('11111111100', $output); - self::assertStringNotContainsString('99999999900', $output); + /** @Then the data section should contain the encoding failure payload */ + self::assertStringContainsString('data={"error":"encoding_failed"}', $this->logStream->contents()); } - public function testLogRedactsMultipleScalarFieldsAfterNestedArray(): void + public function testRedactWhenEmailStartsWithAtSignThenLeavesValueIntact(): void { - /** @Given a structured logger with redaction for two fields */ + /** @Given a structured logger with email redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'batch-service') - ->withRedactions(DocumentRedaction::from(fields: ['document', 'taxId'], visibleSuffixLength: 3)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) ->build(); - /** @When logging with a nested array followed by multiple scalar fields that need redaction */ - $logger->info(message: 'batch.process', context: [ - 'metadata' => ['document' => '11111111100'], - 'document' => '22222222200', - 'taxId' => '33333333300', - 'status' => 'active' - ]); - - /** @Then all three documents should be redacted and status preserved */ - $output = $this->streamContents(); + /** @When logging with an email whose @ sign is at position zero */ + $logger->info(message: 'user.registered', context: ['email' => '@example.com']); - self::assertStringContainsString('********100', $output); - self::assertStringContainsString('********200', $output); - self::assertStringContainsString('********300', $output); - self::assertStringNotContainsString('11111111100', $output); - self::assertStringNotContainsString('22222222200', $output); - self::assertStringNotContainsString('33333333300', $output); - self::assertStringContainsString('active', $output); + /** @Then the result keeps the @ at the start because the search begins at offset zero */ + self::assertStringContainsString('"email":"@example.com"', $this->logStream->contents()); } - public function testLogRedactsNestedArrayPreservingAllSiblingFields(): void + public function testRedactWhenNestedArrayThenMasksTargetAndKeepsSiblings(): void { /** @Given a structured logger with document redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'nested-service') ->withRedactions(DocumentRedaction::default()) ->build(); @@ -898,22 +796,56 @@ public function testLogRedactsNestedArrayPreservingAllSiblingFields(): void ]); /** @Then the document should be redacted within the nested structure */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); self::assertStringContainsString('********900', $output); self::assertStringNotContainsString('12345678900', $output); - /** @And all sibling fields in the nested array must be preserved */ - self::assertStringContainsString('"name":"John"', $output); - self::assertStringContainsString('"role":"admin"', $output); - self::assertStringContainsString('"active":true', $output); + /** @And all sibling fields in the nested array must be preserved */ + self::assertStringContainsString('"name":"John"', $output); + self::assertStringContainsString('"role":"admin"', $output); + self::assertStringContainsString('"active":true', $output); + } + + #[DataProvider('passwordsOfVaryingLength')] + public function testRedactWhenPasswordVariesInLengthThenMaskIsFixedWidth(string $password): void + { + /** @Given a password whose length varies across runs */ + /** @And a structured logger with password redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(PasswordRedaction::default()) + ->build(); + + /** @When logging with the provided password */ + $logger->info(message: 'login.attempt', context: ['password' => $password]); + + /** @Then the mask is a fixed-width run independent of the input length */ + self::assertStringContainsString('"password":"********"', $this->logStream->contents()); + } + + public function testRedactWhenPhoneIsMultibyteThenSuffixCountsCharacters(): void + { + /** @Given a structured logger with phone redaction and a multibyte suffix */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'contact-service') + ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 3)) + ->build(); + + /** @When logging with a phone that ends with a multibyte character */ + $logger->info(message: 'contact.updated', context: ['phone' => '+5511ÿÿÿ']); + + /** @Then the visible suffix should contain whole characters, not bytes */ + self::assertStringContainsString('"phone":"*****ÿÿÿ"', $this->logStream->contents()); } - public function testLogRedactsDeeplyNestedFields(): void + public function testRedactWhenDeeplyNestedThenMasksTargetsAndKeepsSibling(): void { /** @Given a structured logger with document redaction */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'deep-service') ->withRedactions(DocumentRedaction::default()) ->build(); @@ -930,7 +862,7 @@ public function testLogRedactsDeeplyNestedFields(): void ]); /** @Then the deeply nested document should be redacted */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); self::assertStringContainsString('********900', $output); self::assertStringNotContainsString('12345678900', $output); @@ -943,154 +875,151 @@ public function testLogRedactsDeeplyNestedFields(): void self::assertStringNotContainsString('99988877700', $output); } - public function testLogWithCustomTemplate(): void + public function testRedactWhenNameShorterThanVisibleThenLeavesValueIntact(): void { - /** @Given a structured logger with a custom template */ + /** @Given a structured logger with name redaction configured to show 10 characters */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") - ->withComponent(component: 'custom-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 10)) ->build(); - /** @When logging an info entry */ - $logger->info(message: 'custom.event', context: ['value' => 42]); + /** @When logging with a name shorter than the visible length */ + $logger->info(message: 'user.check', context: ['name' => 'Ana']); - /** @Then the output should follow the custom format */ - $output = $this->streamContents(); + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); - self::assertStringContainsString('custom-service', $output); - self::assertStringContainsString('custom.event', $output); - self::assertStringContainsString('"value":42', $output); - self::assertStringNotContainsString('component=', $output); + self::assertStringContainsString('"name":"Ana"', $output); + self::assertStringNotContainsString('*', $output); } - public function testLogWithDefaultTemplate(): void + public function testWithContextWhenCustomTemplateThenTemplateStillApplies(): void { - /** @Given a structured logger using the default template */ + /** @Given a structured logger with a custom template */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'default-service') + ->withStream(stream: $this->logStream->handle()) + ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") + ->withComponent(component: 'template-service') ->build(); - /** @When logging an info entry */ - $logger->info(message: 'default.event'); + /** @And a contextual logger derived from it */ + $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-tmpl')); - /** @Then the output should use the default template format */ - $output = $this->streamContents(); + /** @When logging through the contextual logger */ + $contextual->info(message: 'template.event'); - self::assertStringContainsString('component=default-service', $output); - self::assertStringContainsString('level=INFO', $output); - self::assertStringContainsString('key=default.event', $output); - self::assertStringContainsString('correlation_id=', $output); + /** @Then the custom template should be preserved */ + $output = $this->logStream->contents(); + + self::assertStringNotContainsString('component=', $output); + self::assertStringContainsString('template-service', $output); } - public function testWithContextPreservesRedactions(): void + public function testLogWhenContextDerivedThenContextualWritesCorrelationId(): void { - /** @Given a structured logger with a redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'secure-service') - ->withRedactions( - DocumentRedaction::from( - fields: ['secret'], - visibleSuffixLength: 3 - ) - ) + /** @Given a structured logger */ + $original = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') ->build(); - /** @When creating a contextual logger and logging */ - $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-preserve')); - $contextual->error(message: 'secure.action', context: ['secret' => 'my-secret-value']); + /** @And a contextual logger derived from it */ + $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999')); - /** @Then the redaction should still be applied */ - $output = $this->streamContents(); + /** @When logging from the contextual instance */ + $contextual->info(message: 'auth.success'); - self::assertStringNotContainsString('my-secret-value', $output); - self::assertStringContainsString('correlation_id=ctx-preserve', $output); + /** @Then the contextual line carries the correlation ID */ + self::assertStringContainsString('correlation_id=ctx-999', $this->logStream->contents()); } - public function testWithContextPreservesCustomTemplate(): void + public function testLogWhenDataHasSlashesAndUnicodeThenLeavesThemUnescaped(): void { - /** @Given a structured logger with a custom template */ + /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") - ->withComponent(component: 'template-service') + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'encoding-service') ->build(); - /** @When creating a contextual logger and logging */ - $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-tmpl')); - $contextual->info(message: 'template.event'); + /** @When logging with data containing slashes and Unicode characters */ + $logger->info(message: 'path.resolved', context: [ + 'url' => 'https://example.com/api/v1/users', + 'name' => 'José María' + ]); - /** @Then the custom template should be preserved */ - $output = $this->streamContents(); + /** @Then the slashes should not be escaped */ + $output = $this->logStream->contents(); - self::assertStringNotContainsString('component=', $output); - self::assertStringContainsString('template-service', $output); + self::assertStringContainsString('https://example.com/api/v1/users', $output); + self::assertStringNotContainsString('https:\/\/example.com\/api\/v1\/users', $output); + + /** @And the Unicode characters should not be escaped */ + self::assertStringContainsString('José María', $output); + self::assertStringNotContainsString('\u00e9', $output); } - public function testBuilderAccumulatesRedactionsFromMultipleCalls(): void + public function testRedactWhenNameLengthEqualsVisibleThenLeavesValueIntact(): void { - /** @Given a structured logger built with redactions added in separate calls */ + /** @Given a structured logger with name redaction where visible length equals value length */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'multi-call-service') - ->withRedactions(DocumentRedaction::default()) - ->withRedactions(EmailRedaction::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 3)) ->build(); - /** @When logging with both sensitive fields */ - $logger->info(message: 'multi.call', context: [ - 'document' => '12345678900', - 'email' => 'john@example.com' - ]); + /** @When logging with a name whose length equals the visible prefix length */ + $logger->info(message: 'user.check', context: ['name' => 'Ana']); - /** @Then both fields should be redacted */ - $output = $this->streamContents(); + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); - self::assertStringContainsString('jo**@example.com', $output); - self::assertStringNotContainsString('12345678900', $output); - self::assertStringNotContainsString('john@example.com', $output); + self::assertStringContainsString('"name":"Ana"', $output); + self::assertStringNotContainsString('*', $output); } - public function testLogWithNameRedactionPreservesMultibyteCharacterBoundaries(): void + public function testRedactWhenPhoneShorterThanVisibleThenLeavesValueIntact(): void { - /** @Given a structured logger with name redaction and a multibyte value */ + /** @Given a structured logger with phone redaction configured to show 10 characters */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'profile-service') - ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 2)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 10)) ->build(); - /** @When logging with a name that contains a multibyte character */ - $logger->info(message: 'profile.viewed', context: ['name' => 'Ümit']); + /** @When logging with a phone shorter than the visible length */ + $logger->info(message: 'sms.check', context: ['phone' => '1234']); - /** @Then the visible prefix should contain whole characters, not bytes */ - self::assertStringContainsString('"name":"Üm**"', $this->streamContents()); + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"phone":"1234"', $output); + self::assertStringNotContainsString('*', $output); } - public function testLogWithPhoneRedactionPreservesMultibyteCharacterBoundaries(): void + public function testLogWhenContextBoundAfterCreationThenWritesCorrelationId(): void { - /** @Given a structured logger with phone redaction and a multibyte suffix */ + /** @Given a structured logger */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'contact-service') - ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 3)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'order-service') ->build(); - /** @When logging with a phone that ends with a multibyte character */ - $logger->info(message: 'contact.updated', context: ['phone' => '+5511ÿÿÿ']); + /** @And a contextual logger derived after creation */ + $loggerWithContext = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); - /** @Then the visible suffix should contain whole characters, not bytes */ - self::assertStringContainsString('"phone":"*****ÿÿÿ"', $this->streamContents()); + /** @When logging from the contextual logger */ + $loggerWithContext->info(message: 'order.placed', context: ['orderId' => 42]); + + /** @Then the output should contain the correlation ID */ + self::assertStringContainsString('correlation_id=req-abc-123', $this->logStream->contents()); } - public function testLogWithDocumentRedactionPreservesMultibyteCharacterBoundaries(): void + public function testRedactWhenDocumentIsMultibyteThenSuffixCountsCharacters(): void { /** @Given a structured logger with document redaction and a multibyte suffix */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3)) ->build(); @@ -1099,46 +1028,127 @@ public function testLogWithDocumentRedactionPreservesMultibyteCharacterBoundarie $logger->info(message: 'kyc.check', context: ['document' => '1234ção']); /** @Then the visible suffix should contain whole characters, not bytes */ - self::assertStringContainsString('"document":"****ção"', $this->streamContents()); + self::assertStringContainsString('"document":"****ção"', $this->logStream->contents()); } - public function testLogWithEmailRedactionPreservesMultibyteCharacterBoundariesInLocalPart(): void + public function testRedactWhenPhoneLengthEqualsVisibleThenLeavesValueIntact(): void { - /** @Given a structured logger with email redaction */ + /** @Given a structured logger with phone redaction where visible length equals value length */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4)) ->build(); - /** @When logging with an email whose local part contains multibyte characters */ - $logger->info(message: 'user.registered', context: ['email' => 'Ümit@example.com']); + /** @When logging with a phone whose length equals the visible suffix length */ + $logger->info(message: 'sms.check', context: ['phone' => '1234']); - /** @Then the prefix is taken from characters, not bytes */ - self::assertStringContainsString('"email":"Üm**@example.com"', $this->streamContents()); + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"phone":"1234"', $output); + self::assertStringNotContainsString('*', $output); } - public function testLogWithEmailRedactionStartsAtSignSearchFromTheBeginning(): void + public function testRedactWhenDocumentShorterThanVisibleThenLeavesValueIntact(): void { - /** @Given a structured logger with email redaction */ + /** @Given a structured logger with document redaction configured to show 10 characters */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) - ->withComponent(component: 'user-service') - ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 10)) ->build(); - /** @When logging with an email whose @ sign is at position zero */ - $logger->info(message: 'user.registered', context: ['email' => '@example.com']); + /** @When logging with a document shorter than the visible length */ + $logger->info(message: 'kyc.check', context: ['document' => 'abc']); - /** @Then the result keeps the @ at the start because the search begins at offset zero */ - self::assertStringContainsString('"email":"@example.com"', $this->streamContents()); + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"document":"abc"', $output); + self::assertStringNotContainsString('*', $output); + } + + public function testRedactWhenDocumentLengthEqualsVisibleThenLeavesValueIntact(): void + { + /** @Given a structured logger with document redaction where visible length equals value length */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3)) + ->build(); + + /** @When logging with a document whose length equals the visible suffix length */ + $logger->info(message: 'kyc.check', context: ['document' => 'abc']); + + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"document":"abc"', $output); + self::assertStringNotContainsString('*', $output); + } + + public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies(): void + { + /** @Given a structured logger with a redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'secure-service') + ->withRedactions( + DocumentRedaction::from( + fields: ['secret'], + visibleSuffixLength: 3 + ) + ) + ->build(); + + /** @And a contextual logger derived from it */ + $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-preserve')); + + /** @When logging through the contextual logger */ + $contextual->error(message: 'secure.action', context: ['secret' => 'my-secret-value']); + + /** @Then the redaction should still be applied */ + $output = $this->logStream->contents(); + + self::assertStringNotContainsString('my-secret-value', $output); + self::assertStringContainsString('correlation_id=ctx-preserve', $output); + } + + public function testRedactWhenScalarsFollowNestedArrayThenMasksAllAndKeepsStatus(): void + { + /** @Given a structured logger with redaction for two fields */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'batch-service') + ->withRedactions(DocumentRedaction::from(fields: ['document', 'taxId'], visibleSuffixLength: 3)) + ->build(); + + /** @When logging with a nested array followed by multiple scalar fields that need redaction */ + $logger->info(message: 'batch.process', context: [ + 'metadata' => ['document' => '11111111100'], + 'document' => '22222222200', + 'taxId' => '33333333300', + 'status' => 'active' + ]); + + /** @Then all three documents should be redacted and status preserved */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('********100', $output); + self::assertStringContainsString('********200', $output); + self::assertStringContainsString('********300', $output); + self::assertStringNotContainsString('11111111100', $output); + self::assertStringNotContainsString('22222222200', $output); + self::assertStringNotContainsString('33333333300', $output); + self::assertStringContainsString('active', $output); } - public function testAppliesAllRedactionsRecursivelyAcrossNestedLevels(): void + public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveFields(): void { /** @Given a StructuredLogger configured with default redactions for password, email, document, phone, and name */ $logger = StructuredLogger::create() - ->withStream(stream: $this->stream) + ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'test') ->withRedactions( PasswordRedaction::default(), @@ -1192,7 +1202,7 @@ public function testAppliesAllRedactionsRecursivelyAcrossNestedLevels(): void /** @Then all sensitive fields are redacted regardless of nesting depth, * and non-sensitive fields pass through unchanged */ - $output = $this->streamContents(); + $output = $this->logStream->contents(); $decoded = json_decode(explode(' data=', $output)[1], true); self::assertSame('ORD-12345', $decoded['order_id']); @@ -1264,9 +1274,46 @@ public function testAppliesAllRedactionsRecursivelyAcrossNestedLevels(): void ); } - private function streamContents(): string + public function testRedactWhenEmailLocalPartIsMultibyteThenPrefixCountsCharacters(): void + { + /** @Given a structured logger with email redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) + ->build(); + + /** @When logging with an email whose local part contains multibyte characters */ + $logger->info(message: 'user.registered', context: ['email' => 'Ümit@example.com']); + + /** @Then the prefix is taken from characters, not bytes */ + self::assertStringContainsString('"email":"Üm**@example.com"', $this->logStream->contents()); + } + + public function testRedactWhenEmailLocalPartShorterThanVisibleThenLeavesValueIntact(): void + { + /** @Given a structured logger with email redaction configured to show 10 characters */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 10)) + ->build(); + + /** @When logging with an email whose local part is shorter than the visible length */ + $logger->info(message: 'user.check', context: ['email' => 'ab@test.com']); + + /** @Then the email should remain exactly as-is with no masking asterisks */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"email":"ab@test.com"', $output); + self::assertStringNotContainsString('*', $output); + } + + public static function passwordsOfVaryingLength(): array { - rewind($this->stream); - return stream_get_contents($this->stream); + return [ + 'short password' => ['123'], + 'long password' => ['mySuperLongP@ssw0rd!123'] + ]; } } From 3658227c916e9f359263d15f600e0fadc8ff8778 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 27 Jun 2026 09:04:40 -0300 Subject: [PATCH 2/4] chore: Version the Claude Code agent configuration. Track the .claude rules, hooks, skills, and settings together with the root CLAUDE.md so contributors share the same coding-standard tooling. The per-clone settings.local.json stays ignored. --- .claude/CLAUDE.md | 32 - .claude/hooks/php-ordering-conformance.py | 607 ++++++++++ .../php-prose-punctuation-conformance.py | 176 +++ .claude/rules/github-workflows.md | 78 -- .claude/rules/php-library-architecture.md | 147 +++ .claude/rules/php-library-code-style.md | 1020 +++++++++++++++-- .claude/rules/php-library-documentation.md | 217 +++- .claude/rules/php-library-github-workflows.md | 104 ++ .claude/rules/php-library-modeling.md | 353 ++++-- .claude/rules/php-library-testing.md | 382 +++++- .claude/rules/php-library-tooling.md | 138 +++ .claude/settings.json | 249 ++++ .claude/skills/commit-message/SKILL.md | 119 ++ .claude/skills/tiny-blocks-consume/SKILL.md | 68 ++ .../tiny-blocks-consume/references/catalog.md | 32 + .../scripts/refresh-catalog.py | 102 ++ .claude/skills/tiny-blocks-create/SKILL.md | 158 +++ .../assets/config/.editorconfig | 19 + .../assets/config/.gitattributes | 19 + .../assets/config/.gitignore | 28 + .../tiny-blocks-create/assets/config/Makefile | 74 ++ .../assets/config/composer.json | 70 ++ .../assets/config/infection.json.dist | 23 + .../assets/config/phpcs.xml | 7 + .../assets/config/phpstan.neon.dist | 6 + .../assets/config/phpunit.xml | 39 + .../assets/docs/SECURITY.md | 12 + .../github/ISSUE_TEMPLATE/bug_report.md | 29 + .../github/ISSUE_TEMPLATE/feature_request.md | 17 + .../assets/github/PULL_REQUEST_TEMPLATE.md | 16 + .../assets/github/workflows/ci.yml | 105 ++ CLAUDE.md | 61 + 32 files changed, 4099 insertions(+), 408 deletions(-) delete mode 100644 .claude/CLAUDE.md create mode 100644 .claude/hooks/php-ordering-conformance.py create mode 100644 .claude/hooks/php-prose-punctuation-conformance.py delete mode 100644 .claude/rules/github-workflows.md create mode 100644 .claude/rules/php-library-architecture.md create mode 100644 .claude/rules/php-library-github-workflows.md create mode 100644 .claude/rules/php-library-tooling.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/commit-message/SKILL.md create mode 100644 .claude/skills/tiny-blocks-consume/SKILL.md create mode 100644 .claude/skills/tiny-blocks-consume/references/catalog.md create mode 100644 .claude/skills/tiny-blocks-consume/scripts/refresh-catalog.py create mode 100644 .claude/skills/tiny-blocks-create/SKILL.md create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.editorconfig create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitattributes create mode 100644 .claude/skills/tiny-blocks-create/assets/config/.gitignore create mode 100644 .claude/skills/tiny-blocks-create/assets/config/Makefile create mode 100644 .claude/skills/tiny-blocks-create/assets/config/composer.json create mode 100644 .claude/skills/tiny-blocks-create/assets/config/infection.json.dist create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpcs.xml create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpstan.neon.dist create mode 100644 .claude/skills/tiny-blocks-create/assets/config/phpunit.xml create mode 100644 .claude/skills/tiny-blocks-create/assets/docs/SECURITY.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/PULL_REQUEST_TEMPLATE.md create mode 100644 .claude/skills/tiny-blocks-create/assets/github/workflows/ci.yml create mode 100644 CLAUDE.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md deleted file mode 100644 index 11885b0..0000000 --- a/.claude/CLAUDE.md +++ /dev/null @@ -1,32 +0,0 @@ -# Project - -PHP library (tiny-blocks ecosystem). Self-contained package: immutable models, zero infrastructure -dependencies in core, small public surface area. Public API at `src/` root; implementation details -under `src/Internal/`. - -## Rules - -All coding standards, architecture, naming, testing, and documentation conventions -are defined in `rules/`. Read the applicable rule files before generating any code or documentation. - -## Commands - -- `make test` — run tests with coverage. -- `make mutation-test` — run mutation testing (Infection). -- `make review` — run lint. -- `make help` — list all available commands. - -## Post-change validation - -After any code change, run `make review`, `make test`, and `make mutation-test`. -If any fails, iterate on the fix while respecting all project rules until all pass. -Never deliver code that breaks lint, tests, or leaves surviving mutants. - -## File formatting - -Every file produced or modified must: - -- Use **LF** line endings. Never CRLF. -- Have no trailing whitespace on any line. -- End with a single trailing newline. -- Have no consecutive blank lines (max one blank line between blocks). diff --git a/.claude/hooks/php-ordering-conformance.py b/.claude/hooks/php-ordering-conformance.py new file mode 100644 index 0000000..21a3ec0 --- /dev/null +++ b/.claude/hooks/php-ordering-conformance.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +"""PHP ordering conformance hook for tiny-blocks PHP libraries. + +Self-contained PostToolUse hook on Edit|Write|MultiEdit. Verifies the deterministic +ordering conventions for PHP declarations: + +- Parameter ordering: declaration parameters (constructors, factories, methods, + property promotion) in three tiers, required parameters first, then defaulted + parameters, then a variadic, each tier by identifier length ascending, + alphabetical tie-breaker, semantic pairs preserved. A PHPUnit test method fed by + a data provider is exempt, its parameters are the columns of its data set. +- Member ordering: constants, enum cases, constructor, static methods, instance + methods, in that group order, each group length-ascending with alphabetical + tie-breaker. PHPUnit test classes instead order methods as lifecycle hooks (in + execution order), then other methods, then data providers. + +The analysis is pure (FileUnit in, Violation out) and runs in three passes over +well-formed PHP: a lexical pass blanks every comment, string, and heredoc/nowdoc +body (LITERALS), a structural pass maps every bracket to its pair (bracket_spans); +extraction assigns tokens of interest to their containers by flat walks. Control +flow uses guard clauses only and nesting never exceeds two levels. Reports +violations to stderr and exits 2 to prompt Claude, exits 0 silently if no violations +or the file is out of scope. +""" + +import json +import re +import sys +from dataclasses import dataclass +from enum import Enum +from functools import cached_property +from pathlib import Path +from typing import Final + +# --- Configuration ---------------------------------------------------------- + +# In-scope files: PHP sources under src/ or tests/. +SCOPE_PATTERN: Final = re.compile(r"(^|/)(src|tests)/.+\.php$") + +# Semantic pairs (exhaustive). Natural order wins between +# the two members when both appear in the same parameter list. +SEMANTIC_PAIRS: Final = ( + ("start", "end"), + ("from", "to"), + ("startAt", "endAt"), + ("createdAt", "updatedAt"), + ("before", "after"), + ("min", "max"), +) + +# Each member maps to (first, second, position). Both members keep their natural +# order only when both are present, sorting as a unit at the lead member's key. +PAIR_MEMBER: Final = { + member: (first, second, position) + for first, second in SEMANTIC_PAIRS + for position, member in enumerate((first, second)) +} + +MODIFIERS: Final = ("abstract", "final", "private", "protected", "public", "static") + +# The lexical grammar: every PHP construct that must not be scanned as code. +# Alternatives are ordered, the heredoc label closes via backreference. +LITERALS: Final = re.compile( + r""" + /\*.*?\*/ # block comment + | //[^\n]* # line comment + | \#(?!\[)[^\n]* # hash comment, never a #[ attribute + | <<<[ \t]*(?P['"]?)(?P