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/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)
+
+* [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
new file mode 100644
index 0000000..41b3fb7
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,76 @@
+{
+ "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",
+ "tiny-blocks/collection": "^1.15"
+ },
+ "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"
+ ]
+ }
+}
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..e81e48d
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,9 @@
+parameters:
+ paths:
+ - src
+ level: 9
+ tmpDir: report/phpstan
+ ignoreErrors:
+ - '#mixed#'
+ - '#type specified in iterable type array#'
+ 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/LogFormatter.php b/src/Internal/LogFormatter.php
new file mode 100644
index 0000000..9a17a15
--- /dev/null
+++ b/src/Internal/LogFormatter.php
@@ -0,0 +1,47 @@
+format(DateTimeInterface::ATOM);
+ $encodedData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ $correlationId = is_null($context) ? self::EMPTY_CORRELATION_ID : $context->correlationId;
+
+ return sprintf(
+ $this->template,
+ $timestamp,
+ $this->component,
+ $correlationId,
+ $level->value,
+ $key,
+ $encodedData
+ );
+ }
+}
diff --git a/src/Internal/Redactor/Redactions.php b/src/Internal/Redactor/Redactions.php
new file mode 100644
index 0000000..f95ead4
--- /dev/null
+++ b/src/Internal/Redactor/Redactions.php
@@ -0,0 +1,28 @@
+ $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/Internal/Redactor/Redactor.php b/src/Internal/Redactor/Redactor.php
new file mode 100644
index 0000000..28e2ca3
--- /dev/null
+++ b/src/Internal/Redactor/Redactor.php
@@ -0,0 +1,32 @@
+ $value) {
+ if (is_array($value)) {
+ $data[$key] = $this->redact(data: $value);
+ continue;
+ }
+
+ if (in_array($key, $this->fields, true) && is_string($value)) {
+ $data[$key] = ($this->maskingFunction)($value);
+ }
+ }
+
+ return $data;
+ }
+}
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/LogContext.php b/src/LogContext.php
new file mode 100644
index 0000000..bbe243d
--- /dev/null
+++ b/src/LogContext.php
@@ -0,0 +1,17 @@
+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);
+ }
+}