Skip to content

Commit 5bf9218

Browse files
committed
feat: Implement structured logging with redaction support and add redaction strategies for documents, emails, and phone numbers.
1 parent 637586b commit 5bf9218

19 files changed

Lines changed: 1367 additions & 191 deletions

README.md

Lines changed: 277 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,277 @@
1-
# logger
1+
# Logger
2+
3+
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
4+
5+
* [Overview](#overview)
6+
* [Installation](#installation)
7+
* [How to use](#how-to-use)
8+
* [Basic logging](#basic-logging)
9+
* [Correlation tracking](#correlation-tracking)
10+
* [Sensitive data redaction](#sensitive-data-redaction)
11+
* [Custom log template](#custom-log-template)
12+
* [License](#license)
13+
* [Contributing](#contributing)
14+
15+
<div id='overview'></div>
16+
17+
## Overview
18+
19+
Provides structured logging with support for correlation tracking and configurable sensitive data redaction.
20+
21+
Built on top of [PSR-3](https://www.php-fig.org/psr/psr-3), the library can be used anywhere a `LoggerInterface` is
22+
expected.
23+
24+
<div id='installation'></div>
25+
26+
## Installation
27+
28+
```bash
29+
composer require tiny-blocks/logger
30+
```
31+
32+
<div id='how-to-use'></div>
33+
34+
## How to use
35+
36+
### Basic logging
37+
38+
Create a logger with `StructuredLogger::create()` and use the fluent builder to configure it. All PSR-3 log levels are
39+
supported: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, and `emergency`.
40+
41+
```php
42+
use TinyBlocks\Logger\StructuredLogger;
43+
44+
$logger = StructuredLogger::create()
45+
->withComponent(component: 'order-service')
46+
->build();
47+
48+
$logger->info(message: 'order.placed', context: ['orderId' => 42]);
49+
```
50+
51+
Output (default template, written to `STDERR`):
52+
53+
```
54+
2026-02-21T16:00:00+00:00 component=order-service correlation_id= level=INFO key=order.placed data={"orderId":42}
55+
```
56+
57+
### Correlation tracking
58+
59+
A correlation ID can be attached at creation time or derived later using `withContext`. The original instance is never
60+
mutated.
61+
62+
#### At creation time
63+
64+
```php
65+
use TinyBlocks\Logger\LogContext;
66+
use TinyBlocks\Logger\StructuredLogger;
67+
68+
$logger = StructuredLogger::create()
69+
->withContext(context: LogContext::from(correlationId: 'req-abc-123'))
70+
->withComponent(component: 'payment-service')
71+
->build();
72+
73+
$logger->info(message: 'payment.started', context: ['amount' => 100.50]);
74+
```
75+
76+
#### Derived from an existing logger
77+
78+
```php
79+
use TinyBlocks\Logger\LogContext;
80+
use TinyBlocks\Logger\StructuredLogger;
81+
82+
$logger = StructuredLogger::create()
83+
->withComponent(component: 'payment-service')
84+
->build();
85+
86+
$contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123'));
87+
88+
$contextual->info(message: 'payment.started', context: ['amount' => 100.50]);
89+
```
90+
91+
### Sensitive data redaction
92+
93+
Redaction is optional and configurable. Built-in redaction strategies are provided for common sensitive fields.
94+
Each strategy accepts multiple field name variations and a configurable masking length.
95+
96+
#### Document redaction
97+
98+
Masks all characters except the last N (default: 3).
99+
100+
```php
101+
use TinyBlocks\Logger\StructuredLogger;
102+
use TinyBlocks\Logger\Redactions\DocumentRedaction;
103+
104+
$logger = StructuredLogger::create()
105+
->withComponent(component: 'kyc-service')
106+
->withRedactions(DocumentRedaction::default())
107+
->build();
108+
109+
$logger->info(message: 'kyc.verified', context: ['document' => '12345678900']);
110+
# document → "********900"
111+
```
112+
113+
With custom fields and visible length:
114+
115+
```php
116+
use TinyBlocks\Logger\Redactions\DocumentRedaction;
117+
118+
DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5);
119+
# cpf "12345678900" → "******78900"
120+
# cnpj "12345678000199" → "*********00199"
121+
```
122+
123+
#### Email redaction
124+
125+
Preserves the first N characters of the local part (default: 2) and the full domain.
126+
127+
```php
128+
use TinyBlocks\Logger\StructuredLogger;
129+
use TinyBlocks\Logger\Redactions\EmailRedaction;
130+
131+
$logger = StructuredLogger::create()
132+
->withComponent(component: 'user-service')
133+
->withRedactions(EmailRedaction::default())
134+
->build();
135+
136+
$logger->info(message: 'user.registered', context: ['email' => 'john@example.com']);
137+
# email → "jo**@example.com"
138+
```
139+
140+
With custom fields:
141+
142+
```php
143+
use TinyBlocks\Logger\Redactions\EmailRedaction;
144+
145+
EmailRedaction::from(fields: ['email', 'contact_email', 'recoveryEmail'], visiblePrefixLength: 2);
146+
```
147+
148+
#### Phone redaction
149+
150+
Masks all characters except the last N (default: 4).
151+
152+
```php
153+
use TinyBlocks\Logger\StructuredLogger;
154+
use TinyBlocks\Logger\Redactions\PhoneRedaction;
155+
156+
$logger = StructuredLogger::create()
157+
->withComponent(component: 'notification-service')
158+
->withRedactions(PhoneRedaction::default())
159+
->build();
160+
161+
$logger->info(message: 'sms.sent', context: ['phone' => '+5511999887766']);
162+
# phone → "**********7766"
163+
```
164+
165+
With custom fields:
166+
167+
```php
168+
use TinyBlocks\Logger\Redactions\PhoneRedaction;
169+
170+
PhoneRedaction::from(fields: ['phone', 'mobile', 'whatsapp'], visibleSuffixLength: 4);
171+
```
172+
173+
#### Composing multiple redactions
174+
175+
```php
176+
use TinyBlocks\Logger\StructuredLogger;
177+
use TinyBlocks\Logger\Redactions\DocumentRedaction;
178+
use TinyBlocks\Logger\Redactions\EmailRedaction;
179+
use TinyBlocks\Logger\Redactions\PhoneRedaction;
180+
181+
$logger = StructuredLogger::create()
182+
->withComponent(component: 'user-service')
183+
->withRedactions(
184+
DocumentRedaction::default(),
185+
EmailRedaction::default(),
186+
PhoneRedaction::default()
187+
)
188+
->build();
189+
190+
$logger->info(message: 'user.registered', context: [
191+
'document' => '12345678900',
192+
'email' => 'john@example.com',
193+
'phone' => '+5511999887766',
194+
'name' => 'John'
195+
]);
196+
# document → "********900"
197+
# email → "jo**@example.com"
198+
# phone → "**********7766"
199+
# name → "John" (unchanged)
200+
```
201+
202+
#### Custom redaction
203+
204+
Implement the `Redaction` interface to create your own strategy:
205+
206+
```php
207+
use TinyBlocks\Logger\Redaction;
208+
209+
final readonly class TokenRedaction implements Redaction
210+
{
211+
public function redact(array $data): array
212+
{
213+
foreach ($data as $key => $value) {
214+
if (is_array($value)) {
215+
$data[$key] = $this->redact(data: $value);
216+
continue;
217+
}
218+
219+
if ($key === 'token' && is_string($value)) {
220+
$data[$key] = '***REDACTED***';
221+
}
222+
}
223+
224+
return $data;
225+
}
226+
}
227+
```
228+
229+
Then add it to the logger:
230+
231+
```php
232+
use TinyBlocks\Logger\StructuredLogger;
233+
234+
$logger = StructuredLogger::create()
235+
->withComponent(component: 'auth-service')
236+
->withRedactions(new TokenRedaction())
237+
->build();
238+
239+
$logger->info(message: 'user.logged_in', context: ['token' => 'abc123']);
240+
# token → "***REDACTED***"
241+
```
242+
243+
### Custom log template
244+
245+
The default output template is:
246+
247+
```
248+
%s component=%s correlation_id=%s level=%s key=%s data=%s
249+
```
250+
251+
You can replace it with any `sprintf` compatible template that accepts six string arguments (timestamp, component,
252+
correlationId, level, key, data):
253+
254+
```php
255+
use TinyBlocks\Logger\StructuredLogger;
256+
257+
$logger = StructuredLogger::create()
258+
->withComponent(component: 'custom-service')
259+
->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n")
260+
->build();
261+
262+
$logger->info(message: 'custom.event', context: ['value' => 42]);
263+
# [2026-02-21T16:00:00+00:00] custom-service | | INFO | custom.event | {"value":42}
264+
```
265+
266+
<div id='license'></div>
267+
268+
## License
269+
270+
Logger is licensed under [MIT](LICENSE).
271+
272+
<div id='contributing'></div>
273+
274+
## Contributing
275+
276+
Please follow the [contributing guidelines](https://github.com/tiny-blocks/tiny-blocks/blob/main/CONTRIBUTING.md) to
277+
contribute to the project.

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
},
4646
"require": {
4747
"php": "^8.5",
48-
"psr/log": "^3.0"
48+
"psr/log": "^3.0",
49+
"tiny-blocks/collection": "^1.15"
4950
},
5051
"require-dev": {
5152
"phpunit/phpunit": "^11.5",
@@ -72,4 +73,4 @@
7273
"@test-no-coverage"
7374
]
7475
}
75-
}
76+
}

phpstan.neon.dist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ parameters:
44
level: 9
55
tmpDir: report/phpstan
66
ignoreErrors:
7+
- '#mixed#'
78
- '#type specified in iterable type array#'
8-
- '#Using nullsafe property access#'
99
reportUnmatchedIgnoredErrors: false

src/Internal/LogFormatter.php

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,36 @@
66

77
use DateTimeImmutable;
88
use DateTimeInterface;
9+
use TinyBlocks\Logger\LogContext;
910
use TinyBlocks\Logger\LogLevel;
1011

1112
final readonly class LogFormatter
1213
{
13-
private const string TEMPLATE = "%s component=%s correlationId=%s level=%s key=%s data=%s\n";
14+
private const string DEFAULT_TEMPLATE = "%s component=%s correlation_id=%s level=%s key=%s data=%s\n";
15+
private const string EMPTY_CORRELATION_ID = '';
1416

15-
public function __construct(private string $component)
17+
private function __construct(private string $component, private string $template)
1618
{
1719
}
1820

19-
public function format(LogLevel $level, string $key, array $data, ?LogContext $context = null): string
21+
public static function fromComponent(string $component): LogFormatter
22+
{
23+
return new LogFormatter(component: $component, template: self::DEFAULT_TEMPLATE);
24+
}
25+
26+
public static function fromTemplate(string $component, string $template): LogFormatter
27+
{
28+
return new LogFormatter(component: $component, template: $template);
29+
}
30+
31+
public function format(string $key, array $data, LogLevel $level, ?LogContext $context = null): string
2032
{
2133
$timestamp = new DateTimeImmutable()->format(DateTimeInterface::ATOM);
22-
$correlationId = $context?->correlationId ?? '';
2334
$encodedData = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
35+
$correlationId = is_null($context) ? self::EMPTY_CORRELATION_ID : $context->correlationId;
2436

2537
return sprintf(
26-
self::TEMPLATE,
38+
$this->template,
2739
$timestamp,
2840
$this->component,
2941
$correlationId,

0 commit comments

Comments
 (0)