From 637586b8a1a5e0688965ca1d3faa2c1d2828fa48 Mon Sep 17 00:00:00 2001 From: Gustavo Freze Date: Sat, 21 Feb 2026 13:43:44 -0300 Subject: [PATCH 1/2] feat: Add initial project structure with configuration files and logging implementation. --- .gitattributes | 13 ++++ .github/dependabot.yml | 31 ++++++++ .github/workflows/auto-assign.yml | 25 +++++++ .github/workflows/ci.yml | 87 ++++++++++++++++++++++ .github/workflows/codeql.yml | 35 +++++++++ .gitignore | 7 ++ Makefile | 68 +++++++++++++++++ composer.json | 75 +++++++++++++++++++ infection.json.dist | 23 ++++++ phpstan.neon.dist | 9 +++ phpunit.xml | 37 +++++++++ src/Internal/LogContext.php | 17 +++++ src/Internal/LogFormatter.php | 35 +++++++++ src/Internal/LoggerHandler.php | 83 +++++++++++++++++++++ src/Internal/Redactor/FieldRedactor.php | 36 +++++++++ src/Internal/Redactor/MaskingFunctions.php | 39 ++++++++++ src/Internal/Redactor/Redactions.php | 43 +++++++++++ src/LogLevel.php | 15 ++++ src/Logger.php | 45 +++++++++++ src/Redaction.php | 19 +++++ 20 files changed, 742 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/auto-assign.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 composer.json create mode 100644 infection.json.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 src/Internal/LogContext.php create mode 100644 src/Internal/LogFormatter.php create mode 100644 src/Internal/LoggerHandler.php create mode 100644 src/Internal/Redactor/FieldRedactor.php create mode 100644 src/Internal/Redactor/MaskingFunctions.php create mode 100644 src/Internal/Redactor/Redactions.php create mode 100644 src/LogLevel.php create mode 100644 src/Logger.php create mode 100644 src/Redaction.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a4a3fb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +/tests export-ignore +/vendor export-ignore + +/README.md export-ignore +/LICENSE export-ignore +/Makefile export-ignore +/phpunit.xml export-ignore +/phpstan.neon.dist export-ignore +/infection.json.dist export-ignore + +/.github export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0ce8fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 0 + labels: + - "php" + - "security" + - "dependencies" + groups: + php-security: + applies-to: security-updates + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "build" + labels: + - "dependencies" + - "github-actions" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/auto-assign.yml b/.github/workflows/auto-assign.yml new file mode 100644 index 0000000..d0ba49e --- /dev/null +++ b/.github/workflows/auto-assign.yml @@ -0,0 +1,25 @@ +name: Auto assign issues and pull requests + +on: + issues: + types: + - opened + pull_request: + types: + - opened + +jobs: + run: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - name: Assign issues and pull requests + uses: gustavofreze/auto-assign@2.1.0 + with: + assignees: '${{ vars.ASSIGNEES }}' + github_token: '${{ secrets.GITHUB_TOKEN }}' + allow_self_assign: 'true' + allow_no_assignees: 'true' + assignment_options: 'ISSUE,PULL_REQUEST' \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6be2ffc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + pull_request: + +permissions: + contents: read + +env: + PHP_VERSION: '8.5' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Validate composer.json + run: composer validate --no-interaction + + - name: Install dependencies + run: composer install --no-progress --optimize-autoloader --prefer-dist --no-interaction + + - name: Upload vendor and composer.lock as artifact + uses: actions/upload-artifact@v6 + with: + name: vendor-artifact + path: | + vendor + composer.lock + + auto-review: + name: Auto review + runs-on: ubuntu-latest + needs: build + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . + + - name: Run review + run: composer review + + tests: + name: Tests + runs-on: ubuntu-latest + needs: auto-review + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + tools: composer:2 + + - name: Download vendor artifact from build + uses: actions/download-artifact@v7 + with: + name: vendor-artifact + path: . + + - name: Run tests + run: composer tests diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..4c6d7f7 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,35 @@ +name: Security checks + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + schedule: + - cron: "0 0 * * *" + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ "actions" ] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42b841a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea + +vendor +report +.phpunit.* + +*.lock diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ef9a884 --- /dev/null +++ b/Makefile @@ -0,0 +1,68 @@ +PWD := $(CURDIR) +ARCH := $(shell uname -m) +PLATFORM := + +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 + +RESET := \033[0m +GREEN := \033[0;32m +YELLOW := \033[0;33m + +.DEFAULT_GOAL := help + +.PHONY: configure +configure: ## Configure development environment + @${DOCKER_RUN} composer update --optimize-autoloader + +.PHONY: test +test: ## Run all tests with coverage + @${DOCKER_RUN} composer tests + +.PHONY: test-file +test-file: ## Run tests for a specific file (usage: make test-file FILE=path/to/file) + @${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 + @${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 + +.PHONY: clean +clean: ## Remove dependencies and generated artifacts + @sudo chown -R ${USER}:${USER} ${PWD} + @rm -rf report vendor .phpunit.cache *.lock + +.PHONY: help +help: ## Display this help message + @echo "Usage: make [target]" + @echo "" + @echo "$$(printf '$(GREEN)')Setup$$(printf '$(RESET)')" + @grep -E '^(configure):.*?## .*$$' $(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) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Quality$$(printf '$(RESET)')" + @grep -E '^(review):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Reports$$(printf '$(RESET)')" + @grep -E '^(show-reports):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' + @echo "" + @echo "$$(printf '$(GREEN)')Cleanup$$(printf '$(RESET)')" + @grep -E '^(clean):.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf "$(YELLOW)%-25s$(RESET) %s\n", $$1, $$2}' diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b7d01e7 --- /dev/null +++ b/composer.json @@ -0,0 +1,75 @@ +{ + "name": "tiny-blocks/logger", + "type": "library", + "license": "MIT", + "homepage": "https://github.com/tiny-blocks/logger", + "description": "Provides structured logging with support for correlation tracking and configurable sensitive data redaction.", + "prefer-stable": true, + "minimum-stability": "stable", + "keywords": [ + "psr", + "psr-3", + "logger", + "logging", + "redaction", + "obfuscation", + "tiny-blocks", + "correlation-id", + "structured-logging" + ], + "authors": [ + { + "name": "Gustavo Freze de Araujo Santos", + "homepage": "https://github.com/gustavofreze" + } + ], + "support": { + "issues": "https://github.com/tiny-blocks/logger/issues", + "source": "https://github.com/tiny-blocks/logger" + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "infection/extension-installer": true + } + }, + "autoload": { + "psr-4": { + "TinyBlocks\\Logger\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Test\\TinyBlocks\\Logger\\": "tests/" + } + }, + "require": { + "php": "^8.5", + "psr/log": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.5", + "phpstan/phpstan": "^2.1", + "infection/infection": "^0.32", + "squizlabs/php_codesniffer": "^4.0" + }, + "scripts": { + "test": "php -d memory_limit=2G ./vendor/bin/phpunit --configuration phpunit.xml tests", + "phpcs": "php ./vendor/bin/phpcs --standard=PSR12 --extensions=php ./src", + "phpstan": "php ./vendor/bin/phpstan analyse -c phpstan.neon.dist --quiet --no-progress", + "test-file": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage --filter", + "mutation-test": "php ./vendor/bin/infection --threads=max --logger-html=report/coverage/mutation-report.html --coverage=report/coverage", + "test-no-coverage": "php ./vendor/bin/phpunit --configuration phpunit.xml --no-coverage tests", + "review": [ + "@phpcs", + "@phpstan" + ], + "tests": [ + "@test", + "@mutation-test" + ], + "tests-no-coverage": [ + "@test-no-coverage" + ] + } +} \ No newline at end of file diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..45c49fc --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,23 @@ +{ + "logs": { + "text": "report/infection/logs/infection-text.log", + "summary": "report/infection/logs/infection-summary.log" + }, + "tmpDir": "report/infection/", + "minMsi": 100, + "timeout": 30, + "source": { + "directories": [ + "src" + ] + }, + "phpUnit": { + "configDir": "", + "customPath": "./vendor/bin/phpunit" + }, + "mutators": { + "@default": true + }, + "minCoveredMsi": 100, + "testFramework": "phpunit" +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..b42f89e --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,9 @@ +parameters: + paths: + - src + level: 9 + tmpDir: report/phpstan + ignoreErrors: + - '#type specified in iterable type array#' + - '#Using nullsafe property access#' + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..40c80a2 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,37 @@ + + + + + + src + + + + + + tests + + + + + + + + + + + + + + + + + diff --git a/src/Internal/LogContext.php b/src/Internal/LogContext.php new file mode 100644 index 0000000..38d0a3c --- /dev/null +++ b/src/Internal/LogContext.php @@ -0,0 +1,17 @@ +format(DateTimeInterface::ATOM); + $correlationId = $context?->correlationId ?? ''; + $encodedData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return sprintf( + self::TEMPLATE, + $timestamp, + $this->component, + $correlationId, + $level->value, + $key, + $encodedData + ); + } +} diff --git a/src/Internal/LoggerHandler.php b/src/Internal/LoggerHandler.php new file mode 100644 index 0000000..df960f1 --- /dev/null +++ b/src/Internal/LoggerHandler.php @@ -0,0 +1,83 @@ +logger, + formatter: $this->formatter, + redactions: $this->redactions, + context: $context + ); + } + + public function info(string $key, array $data = []): void + { + $this->log(level: LogLevel::INFO, key: $key, data: $data); + } + + public function warning(string $key, array $data = []): void + { + $this->log(level: LogLevel::WARNING, key: $key, data: $data); + } + + public function error(string $key, array $data = []): void + { + $this->log(level: LogLevel::ERROR, key: $key, data: $data); + } + + private function log(LogLevel $level, string $key, array $data): void + { + $redactedData = $this->redactions->applyTo(data: $data); + $formatted = $this->formatter->format( + level: $level, + key: $key, + data: $redactedData, + context: $this->context + ); + + $this->logger->log(level: strtolower($level->value), message: $formatted); + } +} diff --git a/src/Internal/Redactor/FieldRedactor.php b/src/Internal/Redactor/FieldRedactor.php new file mode 100644 index 0000000..39b0610 --- /dev/null +++ b/src/Internal/Redactor/FieldRedactor.php @@ -0,0 +1,36 @@ +apply(data: $data); + } + + private function apply(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->apply(data: $value); + continue; + } + + if ($key === $this->field && is_string($value)) { + $data[$key] = ($this->maskingFunction)($value); + } + } + + return $data; + } +} diff --git a/src/Internal/Redactor/MaskingFunctions.php b/src/Internal/Redactor/MaskingFunctions.php new file mode 100644 index 0000000..9aee4a7 --- /dev/null +++ b/src/Internal/Redactor/MaskingFunctions.php @@ -0,0 +1,39 @@ + $replacement; + } + + public static function firstNVisible(int $visibleChars): Closure + { + return static function (string $value) use ($visibleChars): string { + $visiblePart = substr($value, self::ZERO, $visibleChars); + $maskedLength = max(self::ZERO, strlen($value) - $visibleChars); + $maskedPart = str_repeat('*', $maskedLength); + + return sprintf('%s%s', $visiblePart, $maskedPart); + }; + } +} diff --git a/src/Internal/Redactor/Redactions.php b/src/Internal/Redactor/Redactions.php new file mode 100644 index 0000000..0013853 --- /dev/null +++ b/src/Internal/Redactor/Redactions.php @@ -0,0 +1,43 @@ +elements = $elements; + } + + public static function from(Redaction ...$redactions): Redactions + { + return new Redactions(...$redactions); + } + + public static function createEmpty(): Redactions + { + return new Redactions(); + } + + public function applyTo(array $data): array + { + foreach ($this->elements as $redaction) { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $redaction->redact(data: $value); + continue; + } + + $data[$key] = $redaction->redact(data: [$key => $value])[$key]; + } + } + + return $data; + } +} diff --git a/src/LogLevel.php b/src/LogLevel.php new file mode 100644 index 0000000..940eafe --- /dev/null +++ b/src/LogLevel.php @@ -0,0 +1,15 @@ + Date: Sat, 21 Feb 2026 19:06:24 -0300 Subject: [PATCH 2/2] feat: Implement structured logging with redaction support and add redaction strategies for documents, emails, and phone numbers. --- README.md | 278 ++++++- composer.json | 5 +- phpstan.neon.dist | 2 +- src/Internal/LogFormatter.php | 22 +- src/Internal/LoggerHandler.php | 83 -- src/Internal/Redactor/MaskingFunctions.php | 39 - src/Internal/Redactor/Redactions.php | 23 +- .../{FieldRedactor.php => Redactor.php} | 14 +- src/Internal/Stream/LogStream.php | 26 + src/{Internal => }/LogContext.php | 2 +- src/LogLevel.php | 8 +- src/Logger.php | 37 +- src/Redaction.php | 5 +- src/Redactions/DocumentRedaction.php | 41 + src/Redactions/EmailRedaction.php | 51 ++ src/Redactions/PhoneRedaction.php | 41 + src/StructuredLogger.php | 68 ++ src/StructuredLoggerBuilder.php | 64 ++ tests/StructuredLoggerTest.php | 749 ++++++++++++++++++ 19 files changed, 1367 insertions(+), 191 deletions(-) delete mode 100644 src/Internal/LoggerHandler.php delete mode 100644 src/Internal/Redactor/MaskingFunctions.php rename src/Internal/Redactor/{FieldRedactor.php => Redactor.php} (57%) create mode 100644 src/Internal/Stream/LogStream.php rename src/{Internal => }/LogContext.php (88%) create mode 100644 src/Redactions/DocumentRedaction.php create mode 100644 src/Redactions/EmailRedaction.php create mode 100644 src/Redactions/PhoneRedaction.php create mode 100644 src/StructuredLogger.php create mode 100644 src/StructuredLoggerBuilder.php create mode 100644 tests/StructuredLoggerTest.php diff --git a/README.md b/README.md index 293e299..7ed4839 100644 --- a/README.md +++ b/README.md @@ -1 +1,277 @@ -# logger \ No newline at end of file +# Logger + +[![License](https://img.shields.io/badge/license-MIT-green)](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) +* [License](#license) +* [Contributing](#contributing) + +
+ +## Overview + +Provides structured logging with support for correlation tracking and configurable sensitive data redaction. + +Built on top of [PSR-3](https://www.php-fig.org/psr/psr-3), the library can be used anywhere a `LoggerInterface` is +expected. + +
+ +## Installation + +```bash +composer require tiny-blocks/logger +``` + +
+ +## How to use + +### Basic logging + +Create a logger with `StructuredLogger::create()` and use the fluent builder to configure it. All PSR-3 log levels are +supported: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, and `emergency`. + +```php +use TinyBlocks\Logger\StructuredLogger; + +$logger = StructuredLogger::create() + ->withComponent(component: 'order-service') + ->build(); + +$logger->info(message: 'order.placed', context: ['orderId' => 42]); +``` + +Output (default template, written to `STDERR`): + +``` +2026-02-21T16:00:00+00:00 component=order-service correlation_id= level=INFO key=order.placed data={"orderId":42} +``` + +### Correlation tracking + +A correlation ID can be attached at creation time or derived later using `withContext`. The original instance is never +mutated. + +#### At creation time + +```php +use TinyBlocks\Logger\LogContext; +use TinyBlocks\Logger\StructuredLogger; + +$logger = StructuredLogger::create() + ->withContext(context: LogContext::from(correlationId: 'req-abc-123')) + ->withComponent(component: 'payment-service') + ->build(); + +$logger->info(message: 'payment.started', context: ['amount' => 100.50]); +``` + +#### Derived from an existing logger + +```php +use TinyBlocks\Logger\LogContext; +use TinyBlocks\Logger\StructuredLogger; + +$logger = StructuredLogger::create() + ->withComponent(component: 'payment-service') + ->build(); + +$contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); + +$contextual->info(message: 'payment.started', context: ['amount' => 100.50]); +``` + +### Sensitive data redaction + +Redaction is optional and configurable. Built-in redaction strategies are provided for common sensitive fields. +Each strategy accepts multiple field name variations and a configurable masking length. + +#### Document redaction + +Masks all characters except the last N (default: 3). + +```php +use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Redactions\DocumentRedaction; + +$logger = StructuredLogger::create() + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + +$logger->info(message: 'kyc.verified', context: ['document' => '12345678900']); +# document → "********900" +``` + +With custom fields and visible length: + +```php +use TinyBlocks\Logger\Redactions\DocumentRedaction; + +DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5); +# cpf "12345678900" → "******78900" +# cnpj "12345678000199" → "*********00199" +``` + +#### Email redaction + +Preserves the first N characters of the local part (default: 2) and the full domain. + +```php +use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Redactions\EmailRedaction; + +$logger = StructuredLogger::create() + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::default()) + ->build(); + +$logger->info(message: 'user.registered', context: ['email' => 'john@example.com']); +# email → "jo**@example.com" +``` + +With custom fields: + +```php +use TinyBlocks\Logger\Redactions\EmailRedaction; + +EmailRedaction::from(fields: ['email', 'contact_email', 'recoveryEmail'], visiblePrefixLength: 2); +``` + +#### Phone redaction + +Masks all characters except the last N (default: 4). + +```php +use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Redactions\PhoneRedaction; + +$logger = StructuredLogger::create() + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::default()) + ->build(); + +$logger->info(message: 'sms.sent', context: ['phone' => '+5511999887766']); +# phone → "**********7766" +``` + +With custom fields: + +```php +use TinyBlocks\Logger\Redactions\PhoneRedaction; + +PhoneRedaction::from(fields: ['phone', 'mobile', 'whatsapp'], visibleSuffixLength: 4); +``` + +#### Composing multiple redactions + +```php +use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Redactions\DocumentRedaction; +use TinyBlocks\Logger\Redactions\EmailRedaction; +use TinyBlocks\Logger\Redactions\PhoneRedaction; + +$logger = StructuredLogger::create() + ->withComponent(component: 'user-service') + ->withRedactions( + DocumentRedaction::default(), + EmailRedaction::default(), + PhoneRedaction::default() + ) + ->build(); + +$logger->info(message: 'user.registered', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com', + 'phone' => '+5511999887766', + 'name' => 'John' +]); +# document → "********900" +# email → "jo**@example.com" +# phone → "**********7766" +# name → "John" (unchanged) +``` + +#### Custom redaction + +Implement the `Redaction` interface to create your own strategy: + +```php +use TinyBlocks\Logger\Redaction; + +final readonly class TokenRedaction implements Redaction +{ + public function redact(array $data): array + { + foreach ($data as $key => $value) { + if (is_array($value)) { + $data[$key] = $this->redact(data: $value); + continue; + } + + if ($key === 'token' && is_string($value)) { + $data[$key] = '***REDACTED***'; + } + } + + return $data; + } +} +``` + +Then add it to the logger: + +```php +use TinyBlocks\Logger\StructuredLogger; + +$logger = StructuredLogger::create() + ->withComponent(component: 'auth-service') + ->withRedactions(new TokenRedaction()) + ->build(); + +$logger->info(message: 'user.logged_in', context: ['token' => 'abc123']); +# token → "***REDACTED***" +``` + +### Custom log template + +The default output template is: + +``` +%s component=%s correlation_id=%s level=%s key=%s data=%s +``` + +You can replace it with any `sprintf` compatible template that accepts six string arguments (timestamp, component, +correlationId, level, key, data): + +```php +use TinyBlocks\Logger\StructuredLogger; + +$logger = StructuredLogger::create() + ->withComponent(component: 'custom-service') + ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") + ->build(); + +$logger->info(message: 'custom.event', context: ['value' => 42]); +# [2026-02-21T16:00:00+00:00] custom-service | | INFO | custom.event | {"value":42} +``` + +
+ +## License + +Logger is licensed under [MIT](LICENSE). + +
+ +## Contributing + +Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to +contribute to the project. diff --git a/composer.json b/composer.json index b7d01e7..41b3fb7 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,8 @@ }, "require": { "php": "^8.5", - "psr/log": "^3.0" + "psr/log": "^3.0", + "tiny-blocks/collection": "^1.15" }, "require-dev": { "phpunit/phpunit": "^11.5", @@ -72,4 +73,4 @@ "@test-no-coverage" ] } -} \ No newline at end of file +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b42f89e..e81e48d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,6 @@ parameters: level: 9 tmpDir: report/phpstan ignoreErrors: + - '#mixed#' - '#type specified in iterable type array#' - - '#Using nullsafe property access#' reportUnmatchedIgnoredErrors: false diff --git a/src/Internal/LogFormatter.php b/src/Internal/LogFormatter.php index f2d8641..9a17a15 100644 --- a/src/Internal/LogFormatter.php +++ b/src/Internal/LogFormatter.php @@ -6,24 +6,36 @@ use DateTimeImmutable; use DateTimeInterface; +use TinyBlocks\Logger\LogContext; use TinyBlocks\Logger\LogLevel; final readonly class LogFormatter { - private const string TEMPLATE = "%s component=%s correlationId=%s level=%s key=%s data=%s\n"; + private const string DEFAULT_TEMPLATE = "%s component=%s correlation_id=%s level=%s key=%s data=%s\n"; + private const string EMPTY_CORRELATION_ID = ''; - public function __construct(private string $component) + private function __construct(private string $component, private string $template) { } - public function format(LogLevel $level, string $key, array $data, ?LogContext $context = null): string + public static function fromComponent(string $component): LogFormatter + { + return new LogFormatter(component: $component, template: self::DEFAULT_TEMPLATE); + } + + public static function fromTemplate(string $component, string $template): LogFormatter + { + return new LogFormatter(component: $component, template: $template); + } + + public function format(string $key, array $data, LogLevel $level, ?LogContext $context = null): string { $timestamp = new DateTimeImmutable()->format(DateTimeInterface::ATOM); - $correlationId = $context?->correlationId ?? ''; $encodedData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $correlationId = is_null($context) ? self::EMPTY_CORRELATION_ID : $context->correlationId; return sprintf( - self::TEMPLATE, + $this->template, $timestamp, $this->component, $correlationId, diff --git a/src/Internal/LoggerHandler.php b/src/Internal/LoggerHandler.php deleted file mode 100644 index df960f1..0000000 --- a/src/Internal/LoggerHandler.php +++ /dev/null @@ -1,83 +0,0 @@ -logger, - formatter: $this->formatter, - redactions: $this->redactions, - context: $context - ); - } - - public function info(string $key, array $data = []): void - { - $this->log(level: LogLevel::INFO, key: $key, data: $data); - } - - public function warning(string $key, array $data = []): void - { - $this->log(level: LogLevel::WARNING, key: $key, data: $data); - } - - public function error(string $key, array $data = []): void - { - $this->log(level: LogLevel::ERROR, key: $key, data: $data); - } - - private function log(LogLevel $level, string $key, array $data): void - { - $redactedData = $this->redactions->applyTo(data: $data); - $formatted = $this->formatter->format( - level: $level, - key: $key, - data: $redactedData, - context: $this->context - ); - - $this->logger->log(level: strtolower($level->value), message: $formatted); - } -} diff --git a/src/Internal/Redactor/MaskingFunctions.php b/src/Internal/Redactor/MaskingFunctions.php deleted file mode 100644 index 9aee4a7..0000000 --- a/src/Internal/Redactor/MaskingFunctions.php +++ /dev/null @@ -1,39 +0,0 @@ - $replacement; - } - - public static function firstNVisible(int $visibleChars): Closure - { - return static function (string $value) use ($visibleChars): string { - $visiblePart = substr($value, self::ZERO, $visibleChars); - $maskedLength = max(self::ZERO, strlen($value) - $visibleChars); - $maskedPart = str_repeat('*', $maskedLength); - - return sprintf('%s%s', $visiblePart, $maskedPart); - }; - } -} diff --git a/src/Internal/Redactor/Redactions.php b/src/Internal/Redactor/Redactions.php index 0013853..f95ead4 100644 --- a/src/Internal/Redactor/Redactions.php +++ b/src/Internal/Redactor/Redactions.php @@ -4,30 +4,15 @@ namespace TinyBlocks\Logger\Internal\Redactor; +use TinyBlocks\Collection\Collection; use TinyBlocks\Logger\Redaction; -final readonly class Redactions +final class Redactions extends Collection { - private array $elements; - - private function __construct(Redaction ...$elements) - { - $this->elements = $elements; - } - - public static function from(Redaction ...$redactions): Redactions - { - return new Redactions(...$redactions); - } - - public static function createEmpty(): Redactions - { - return new Redactions(); - } - public function applyTo(array $data): array { - foreach ($this->elements as $redaction) { + /** @var Redaction $redaction */ + foreach ($this as $redaction) { foreach ($data as $key => $value) { if (is_array($value)) { $data[$key] = $redaction->redact(data: $value); diff --git a/src/Internal/Redactor/FieldRedactor.php b/src/Internal/Redactor/Redactor.php similarity index 57% rename from src/Internal/Redactor/FieldRedactor.php rename to src/Internal/Redactor/Redactor.php index 39b0610..28e2ca3 100644 --- a/src/Internal/Redactor/FieldRedactor.php +++ b/src/Internal/Redactor/Redactor.php @@ -7,26 +7,22 @@ use Closure; use TinyBlocks\Logger\Redaction; -final readonly class FieldRedactor implements Redaction +final readonly class Redactor implements Redaction { - public function __construct(private string $field, private Closure $maskingFunction) + /** @param string[] $fields */ + public function __construct(private array $fields, private Closure $maskingFunction) { } public function redact(array $data): array - { - return $this->apply(data: $data); - } - - private function apply(array $data): array { foreach ($data as $key => $value) { if (is_array($value)) { - $data[$key] = $this->apply(data: $value); + $data[$key] = $this->redact(data: $value); continue; } - if ($key === $this->field && is_string($value)) { + if (in_array($key, $this->fields, true) && is_string($value)) { $data[$key] = ($this->maskingFunction)($value); } } diff --git a/src/Internal/Stream/LogStream.php b/src/Internal/Stream/LogStream.php new file mode 100644 index 0000000..144dd27 --- /dev/null +++ b/src/Internal/Stream/LogStream.php @@ -0,0 +1,26 @@ +resource = $resource; + } + + public static function from(mixed $resource = null): LogStream + { + return new LogStream(resource: $resource ?? STDERR); + } + + public function write(string $content): void + { + fwrite($this->resource, $content); + } +} diff --git a/src/Internal/LogContext.php b/src/LogContext.php similarity index 88% rename from src/Internal/LogContext.php rename to src/LogContext.php index 38d0a3c..bbe243d 100644 --- a/src/Internal/LogContext.php +++ b/src/LogContext.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Logger\Internal; +namespace TinyBlocks\Logger; final readonly class LogContext { diff --git a/src/LogLevel.php b/src/LogLevel.php index 940eafe..954d60d 100644 --- a/src/LogLevel.php +++ b/src/LogLevel.php @@ -4,12 +4,14 @@ namespace TinyBlocks\Logger; -/** - * Represents the severity level of a log entry. - */ enum LogLevel: string { case INFO = 'INFO'; case ERROR = 'ERROR'; + case DEBUG = 'DEBUG'; + case ALERT = 'ALERT'; + case NOTICE = 'NOTICE'; case WARNING = 'WARNING'; + case CRITICAL = 'CRITICAL'; + case EMERGENCY = 'EMERGENCY'; } diff --git a/src/Logger.php b/src/Logger.php index d415fa2..2c90859 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -4,42 +4,25 @@ namespace TinyBlocks\Logger; -use TinyBlocks\Logger\Internal\LogContext; +use Psr\Log\LoggerInterface; /** * Defines a structured logging contract with support for correlation tracking and data redaction. + * + * Extends PSR-3 {@see LoggerInterface} to ensure compatibility with any PSR-3 consumer. + * + * Implementations must support immutable context propagation: calling {@see withContext} + * returns a new instance without mutating the original. */ -interface Logger +interface Logger extends LoggerInterface { /** - * Creates a new Logger instance with the given correlation context. + * Creates a new Logger instance bound to the given correlation context. + * + * The original instance remains unchanged. * * @param LogContext $context The log context containing the correlation ID. * @return static A new Logger instance bound to the given context. */ public function withContext(LogContext $context): static; - - /** - * Logs an informational message. - * - * @param string $key A key identifying the log entry. - * @param array $data Optional structured data to include. - */ - public function info(string $key, array $data = []): void; - - /** - * Logs a warning message. - * - * @param string $key A key identifying the log entry. - * @param array $data Optional structured data to include. - */ - public function warning(string $key, array $data = []): void; - - /** - * Logs an error message. - * - * @param string $key A key identifying the log entry. - * @param array $data Optional structured data to include. - */ - public function error(string $key, array $data = []): void; } diff --git a/src/Redaction.php b/src/Redaction.php index df032b1..c7fd158 100644 --- a/src/Redaction.php +++ b/src/Redaction.php @@ -5,7 +5,10 @@ namespace TinyBlocks\Logger; /** - * Defines the contract for redacting sensitive information from log data. + * Defines the contract for redacting sensitive information from structured log data. + * + * Each implementation is responsible for a specific redaction strategy (e.g., masking a field). + * Redaction is applied recursively to nested arrays. */ interface Redaction { diff --git a/src/Redactions/DocumentRedaction.php b/src/Redactions/DocumentRedaction.php new file mode 100644 index 0000000..6e57937 --- /dev/null +++ b/src/Redactions/DocumentRedaction.php @@ -0,0 +1,41 @@ +redactor = new Redactor( + fields: $fields, + maskingFunction: static function (string $value) use ($visibleSuffixLength): string { + $maskedLength = max(0, strlen($value) - $visibleSuffixLength); + return sprintf('%s%s', str_repeat('*', $maskedLength), substr($value, -$visibleSuffixLength)); + } + ); + } + + public static function from(array $fields, int $visibleSuffixLength): DocumentRedaction + { + return new DocumentRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); + } + + public static function default(): DocumentRedaction + { + return self::from(fields: ['document'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + } + + public function redact(array $data): array + { + return $this->redactor->redact(data: $data); + } +} diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php new file mode 100644 index 0000000..a333f23 --- /dev/null +++ b/src/Redactions/EmailRedaction.php @@ -0,0 +1,51 @@ +redactor = new Redactor( + fields: $fields, + maskingFunction: static function (string $value) use ($visiblePrefixLength): string { + $atPosition = strpos($value, '@'); + + if ($atPosition === false) { + return str_repeat('*', strlen($value)); + } + + $localPart = substr($value, 0, $atPosition); + $domain = substr($value, $atPosition); + $visiblePrefix = substr($localPart, 0, $visiblePrefixLength); + $maskedSuffix = str_repeat('*', max(0, strlen($localPart) - $visiblePrefixLength)); + + return sprintf('%s%s%s', $visiblePrefix, $maskedSuffix, $domain); + } + ); + } + + public static function from(array $fields, int $visiblePrefixLength): EmailRedaction + { + return new EmailRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength); + } + + public static function default(): EmailRedaction + { + return self::from(fields: ['email'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); + } + + public function redact(array $data): array + { + return $this->redactor->redact(data: $data); + } +} diff --git a/src/Redactions/PhoneRedaction.php b/src/Redactions/PhoneRedaction.php new file mode 100644 index 0000000..1d22140 --- /dev/null +++ b/src/Redactions/PhoneRedaction.php @@ -0,0 +1,41 @@ +redactor = new Redactor( + fields: $fields, + maskingFunction: static function (string $value) use ($visibleSuffixLength): string { + $maskedLength = max(0, strlen($value) - $visibleSuffixLength); + return sprintf('%s%s', str_repeat('*', $maskedLength), substr($value, -$visibleSuffixLength)); + } + ); + } + + public static function from(array $fields, int $visibleSuffixLength): PhoneRedaction + { + return new PhoneRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); + } + + public static function default(): PhoneRedaction + { + return self::from(fields: ['phone'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + } + + public function redact(array $data): array + { + return $this->redactor->redact(data: $data); + } +} diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php new file mode 100644 index 0000000..293eeda --- /dev/null +++ b/src/StructuredLogger.php @@ -0,0 +1,68 @@ +stream, + context: $context, + formatter: $this->formatter, + redactions: $this->redactions + ); + } + + public function log($level, string|Stringable $message, array $context = []): void + { + $logLevel = LogLevel::from(strtoupper((string)$level)); + $redactedData = $this->redactions->applyTo(data: $context); + + $formatted = $this->formatter->format( + key: (string)$message, + data: $redactedData, + level: $logLevel, + context: $this->context + ); + + $this->stream->write(content: $formatted); + } +} diff --git a/src/StructuredLoggerBuilder.php b/src/StructuredLoggerBuilder.php new file mode 100644 index 0000000..537d451 --- /dev/null +++ b/src/StructuredLoggerBuilder.php @@ -0,0 +1,64 @@ +stream = $stream; + return $this; + } + + public function withContext(LogContext $context): StructuredLoggerBuilder + { + $this->context = $context; + return $this; + } + + public function withTemplate(string $template): StructuredLoggerBuilder + { + $this->template = $template; + return $this; + } + + public function withComponent(string $component): StructuredLoggerBuilder + { + $this->component = $component; + return $this; + } + + 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), + context: $this->context, + formatter: $formatter, + redactions: Redactions::createFrom(elements: $this->redactions) + ); + } +} diff --git a/tests/StructuredLoggerTest.php b/tests/StructuredLoggerTest.php new file mode 100644 index 0000000..b46a7cf --- /dev/null +++ b/tests/StructuredLoggerTest.php @@ -0,0 +1,749 @@ +stream = fopen('php://memory', 'r+'); + } + + protected function tearDown(): void + { + if (is_resource($this->stream)) { + fclose($this->stream); + } + } + + public function testLogInfo(): 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') + ->build(); + + /** @When logging a warning entry */ + $logger->warning(message: 'stock.low', context: ['productId' => 7, 'remaining' => 2]); + + /** @Then the output should contain the warning level */ + $output = $this->streamContents(); + + self::assertStringContainsString('level=WARNING', $output); + self::assertStringContainsString('key=stock.low', $output); + } + + public function testLogError(): void + { + /** @Given a structured logger */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'payment-service') + ->build(); + + /** @When logging an error entry */ + $logger->error(message: 'payment.failed', context: ['reason' => 'timeout']); + + /** @Then the output should contain the error level */ + $output = $this->streamContents(); + + self::assertStringContainsString('level=ERROR', $output); + self::assertStringContainsString('key=payment.failed', $output); + } + + public function testLogDebug(): void + { + /** @Given a structured logger */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'debug-service') + ->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 no data */ + $logger->info(message: 'heartbeat'); + + /** @Then the output should contain an empty JSON array for data */ + self::assertStringContainsString('data=[]', $this->streamContents()); + } + + public function testLogWithoutContextHasEmptyCorrelationId(): void + { + /** @Given a structured logger without any context */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'no-context-service') + ->build(); + + /** @When logging without a context */ + $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); + } + + public function testLogPreservesSlashesAndUnicodeInData(): 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') + ->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]); + + /** @Then the output should contain the correlation ID */ + self::assertStringContainsString('correlation_id=req-abc-123', $this->streamContents()); + } + + public function testLogWithCorrelationIdFromCreation(): void + { + /** @Given a structured logger created with a correlation context */ + $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') + ->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'); + + /** @Then the original log should not contain the correlation ID and the contextual one should */ + $lines = array_filter(explode("\n", $this->streamContents())); + + self::assertStringNotContainsString('correlation_id=ctx-999', $lines[0]); + self::assertStringContainsString('correlation_id=ctx-999', $lines[1]); + } + + public function testLogWithDocumentRedaction(): void + { + /** @Given a structured logger with document redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'payment-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a document field */ + $logger->error(message: 'payment.failed', context: ['document' => '12345678900', 'amount' => 100.50]); + + /** @Then the document should be redacted showing only the last 3 characters */ + $output = $this->streamContents(); + + self::assertStringContainsString('********900', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringContainsString('100.5', $output); + } + + public function testLogWithDocumentRedactionOnMultipleFields(): void + { + /** @Given a structured logger with document redaction targeting multiple field names */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5)) + ->build(); + + /** @When logging with both fields */ + $logger->info(message: 'kyc.verified', context: [ + 'cpf' => '12345678900', + 'cnpj' => '12345678000199' + ]); + + /** @Then both fields should be redacted showing only the last 5 characters */ + $output = $this->streamContents(); + + self::assertStringContainsString('******78900', $output); + self::assertStringContainsString('*********00199', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringNotContainsString('12345678000199', $output); + } + + public function testLogWithDocumentRedactionWhenValueIsShorterThanVisibleLength(): void + { + /** @Given a structured logger with document redaction configured to show 10 characters */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 10)) + ->build(); + + /** @When logging with a document shorter than the visible length */ + $logger->info(message: 'kyc.check', context: ['document' => 'abc']); + + /** @Then the value should remain exactly as-is with no masking asterisks */ + $output = $this->streamContents(); + + self::assertStringContainsString('"document":"abc"', $output); + self::assertStringNotContainsString('*', $output); + } + + public function testLogWithDocumentRedactionExactLengthMatch(): void + { + /** @Given a structured logger with document redaction where visible length equals value length */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->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->streamContents(); + + self::assertStringContainsString('"document":"abc"', $output); + self::assertStringNotContainsString('*', $output); + } + + public function testLogWithEmailRedaction(): void + { + /** @Given a structured logger with email redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::default()) + ->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(); + + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringNotContainsString('john@example.com', $output); + } + + public function testLogWithEmailRedactionOnMultipleFields(): void + { + /** @Given a structured logger with email redaction targeting multiple field names */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'notification-service') + ->withRedactions( + EmailRedaction::from( + fields: ['email', 'contact_email', 'recoveryEmail'], + visiblePrefixLength: 2 + ) + ) + ->build(); + + /** @When logging with multiple email field variations */ + $logger->info(message: 'notification.sent', context: [ + 'email' => 'john@example.com', + 'contact_email' => 'jane@corp.io', + 'recoveryEmail' => 'admin@recovery.org' + ]); + + /** @Then all email fields should be redacted */ + $output = $this->streamContents(); + + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringContainsString('ja**@corp.io', $output); + self::assertStringContainsString('ad***@recovery.org', $output); + } + + public function testLogWithEmailRedactionWithoutAtSign(): void + { + /** @Given a structured logger with email redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::default()) + ->build(); + + /** @When logging with an invalid email (no @ sign) */ + $logger->info(message: 'user.attempt', context: ['email' => 'invalidemail']); + + /** @Then the entire value should be fully masked */ + $output = $this->streamContents(); + + self::assertStringContainsString('************', $output); + self::assertStringNotContainsString('invalidemail', $output); + } + + public function testLogWithEmailRedactionWhenLocalPartIsShorterThanVisibleLength(): void + { + /** @Given a structured logger with email redaction configured to show 10 characters */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->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->streamContents(); + + self::assertStringContainsString('"email":"ab@test.com"', $output); + self::assertStringNotContainsString('*', $output); + } + + public function testLogWithPhoneRedaction(): void + { + /** @Given a structured logger with phone redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::default()) + ->build(); + + /** @When logging with a phone field */ + $logger->info(message: 'sms.sent', context: ['phone' => '+5511999887766']); + + /** @Then the phone should be redacted showing only the last 4 characters */ + $output = $this->streamContents(); + + self::assertStringContainsString('**********7766', $output); + self::assertStringNotContainsString('+5511999887766', $output); + } + + public function testLogWithPhoneRedactionOnMultipleFields(): void + { + /** @Given a structured logger with phone redaction targeting multiple field names */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'contact-service') + ->withRedactions( + PhoneRedaction::from( + fields: ['phone', 'mobile', 'whatsapp'], + visibleSuffixLength: 4 + ) + ) + ->build(); + + /** @When logging with multiple phone field variations */ + $logger->info(message: 'contact.updated', context: [ + 'phone' => '+5511999887766', + 'mobile' => '+5521988776655', + 'whatsapp' => '+5531977665544' + ]); + + /** @Then all phone fields should be redacted */ + $output = $this->streamContents(); + + self::assertStringContainsString('**********7766', $output); + self::assertStringContainsString('**********6655', $output); + self::assertStringContainsString('**********5544', $output); + } + + public function testLogWithPhoneRedactionWhenValueIsShorterThanVisibleLength(): void + { + /** @Given a structured logger with phone redaction configured to show 10 characters */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 10)) + ->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 phone whose length equals the visible suffix 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 testLogWithMultipleRedactions(): void + { + /** @Given a structured logger with multiple redactions */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'user-service') + ->withRedactions( + DocumentRedaction::default(), + EmailRedaction::default(), + PhoneRedaction::default() + ) + ->build(); + + /** @When logging with multiple sensitive fields */ + $logger->info(message: 'user.registered', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com', + 'phone' => '+5511999887766', + 'name' => 'John' + ]); + + /** @Then each field should be redacted according to its rule */ + $output = $this->streamContents(); + + self::assertStringContainsString('********900', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringContainsString('**********7766', $output); + self::assertStringContainsString('John', $output); + } + + public function testLogWithoutRedaction(): void + { + /** @Given a structured logger without any redactions */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'simple-service') + ->build(); + + /** @When logging with data that could be sensitive */ + $logger->info(message: 'data.processed', context: ['document' => '12345678900']); + + /** @Then the data should appear unmodified */ + self::assertStringContainsString('12345678900', $this->streamContents()); + } + + public function testLogRedactsNestedArrayAndScalarFieldsInSameLevel(): void + { + /** @Given a structured logger with document redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'multi-level-service') + ->withRedactions(DocumentRedaction::default()) + ->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(); + + self::assertStringContainsString('********100', $output); + self::assertStringContainsString('********900', $output); + self::assertStringNotContainsString('11111111100', $output); + self::assertStringNotContainsString('99999999900', $output); + } + + public function testLogRedactsMultipleScalarFieldsAfterNestedArray(): void + { + /** @Given a structured logger with redaction for two fields */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->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->streamContents(); + + 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 testLogRedactsNestedArrayPreservingAllSiblingFields(): void + { + /** @Given a structured logger with document redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'nested-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a nested array containing the target field and multiple sibling fields */ + $logger->info(message: 'nested.check', context: [ + 'profile' => [ + 'document' => '12345678900', + 'name' => 'John', + 'role' => 'admin', + 'active' => true + ] + ]); + + /** @Then the document should be redacted within the nested structure */ + $output = $this->streamContents(); + + 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); + } + + public function testLogRedactsDeeplyNestedFields(): void + { + /** @Given a structured logger with document redaction */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'deep-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a deeply nested structure where a sub-array precedes a target scalar field */ + $logger->info(message: 'deep.check', context: [ + 'level1' => [ + 'level2' => [ + 'document' => '12345678900', + 'label' => 'deep-value' + ], + 'document' => '99988877700' + ] + ]); + + /** @Then the deeply nested document should be redacted */ + $output = $this->streamContents(); + + self::assertStringContainsString('********900', $output); + self::assertStringNotContainsString('12345678900', $output); + + /** @And the sibling field in the deepest level must be preserved */ + self::assertStringContainsString('"label":"deep-value"', $output); + + /** @And the scalar document after the sub-array must also be redacted */ + self::assertStringContainsString('********700', $output); + self::assertStringNotContainsString('99988877700', $output); + } + + public function testLogWithCustomTemplate(): void + { + /** @Given a structured logger with a custom template */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") + ->withComponent(component: 'custom-service') + ->build(); + + /** @When logging an info entry */ + $logger->info(message: 'custom.event', context: ['value' => 42]); + + /** @Then the output should follow the custom format */ + $output = $this->streamContents(); + + self::assertStringContainsString('custom-service', $output); + self::assertStringContainsString('custom.event', $output); + self::assertStringContainsString('"value":42', $output); + self::assertStringNotContainsString('component=', $output); + } + + public function testLogWithDefaultTemplate(): void + { + /** @Given a structured logger using the default template */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'default-service') + ->build(); + + /** @When logging an info entry */ + $logger->info(message: 'default.event'); + + /** @Then the output should use the default template format */ + $output = $this->streamContents(); + + self::assertStringContainsString('component=default-service', $output); + self::assertStringContainsString('level=INFO', $output); + self::assertStringContainsString('key=default.event', $output); + self::assertStringContainsString('correlation_id=', $output); + } + + public function testWithContextPreservesRedactions(): 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 + ) + ) + ->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']); + + /** @Then the redaction should still be applied */ + $output = $this->streamContents(); + + self::assertStringNotContainsString('my-secret-value', $output); + self::assertStringContainsString('correlation_id=ctx-preserve', $output); + } + + public function testWithContextPreservesCustomTemplate(): void + { + /** @Given a structured logger with a custom template */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") + ->withComponent(component: 'template-service') + ->build(); + + /** @When creating a contextual logger and logging */ + $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-tmpl')); + $contextual->info(message: 'template.event'); + + /** @Then the custom template should be preserved */ + $output = $this->streamContents(); + + self::assertStringNotContainsString('component=', $output); + self::assertStringContainsString('template-service', $output); + } + + public function testBuilderAccumulatesRedactionsFromMultipleCalls(): void + { + /** @Given a structured logger built with redactions added in separate calls */ + $logger = StructuredLogger::create() + ->withStream(stream: $this->stream) + ->withComponent(component: 'multi-call-service') + ->withRedactions(DocumentRedaction::default()) + ->withRedactions(EmailRedaction::default()) + ->build(); + + /** @When logging with both sensitive fields */ + $logger->info(message: 'multi.call', context: [ + 'document' => '12345678900', + 'email' => 'john@example.com' + ]); + + /** @Then both fields should be redacted */ + $output = $this->streamContents(); + + self::assertStringContainsString('********900', $output); + self::assertStringContainsString('jo**@example.com', $output); + self::assertStringNotContainsString('12345678900', $output); + self::assertStringNotContainsString('john@example.com', $output); + } + + private function streamContents(): string + { + rewind($this->stream); + return stream_get_contents($this->stream); + } +}