diff --git a/Makefile b/Makefile index 6915324..1a4b7db 100644 --- a/Makefile +++ b/Makefile @@ -7,13 +7,21 @@ ifeq ($(ARCH),arm64) endif TTY := $(shell [ -t 0 ] && echo -it) +HOST_USER := $(shell id -u):$(shell id -g) PHP_VERSION := $(shell sed -n 's/.*"php": *"^\([0-9]*\.[0-9]*\)".*/\1/p' composer.json) IMAGE_VERSION := 1.0.0 PHP_IMAGE := gustavofreze/php:${PHP_VERSION}-cli-${IMAGE_VERSION} WORKSPACE := /var/www/html -DOCKER_RUN = docker run ${PLATFORM} --rm ${TTY} --net=host -v ${PWD}:${WORKSPACE} ${PHP_IMAGE} +# The runner drops to the calling user, as the twelve service Makefiles and the CLI already do. A +# root runner writes `vendor/`, `reports/` and the PHPStan cache into the bind mount owned by root, +# and then only the owner can remove them: measured at 801 root owned paths from a single +# `make review` in this repository. COMPOSER_HOME moves off /root because that path belongs to root +# inside the image and a uid with no passwd entry cannot write it. +DOCKER_RUN = docker run ${PLATFORM} -u ${HOST_USER} --rm ${TTY} --net=host \ + -e COMPOSER_HOME=/tmp/composer \ + -v ${PWD}:${WORKSPACE} ${PHP_IMAGE} RESET := \033[0m GREEN := \033[0;32m @@ -55,7 +63,6 @@ show-image: ## Show the pinned PHP tooling image .PHONY: clean clean: ## Remove dependencies and generated artifacts - @sudo chown -R ${USER}:${USER} ${PWD} @rm -rf reports vendor .phpunit.cache *.lock .PHONY: help diff --git a/README.md b/README.md index 6ef29ab..14d557c 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,31 @@ + [Sensitive data redaction](#sensitive-data-redaction) - [Strategy catalog](#strategy-catalog) - [Choosing a mask](#choosing-a-mask) + - [Choosing what stays visible](#choosing-what-stays-visible) - [Field name patterns](#field-name-patterns) - [Document redaction](#document-redaction) - [Email redaction](#email-redaction) - [Phone redaction](#phone-redaction) - - [Password redaction](#password-redaction) - [Name redaction](#name-redaction) - - [Visible edges redaction](#visible-edges-redaction) - - [Wordwise redaction](#wordwise-redaction) - - [Full mask redaction](#full-mask-redaction) - - [Scoped redaction](#scoped-redaction) - - [Allowed fields redaction](#allowed-fields-redaction) - - [Removed fields redaction](#removed-fields-redaction) - - [Pattern redaction](#pattern-redaction) + - [Birth date redaction](#birth-date-redaction) + - [Postal code redaction](#postal-code-redaction) + - [Secret redaction](#secret-redaction) + - [Query string redaction](#query-string-redaction) + - [Query parameters redaction](#query-parameters-redaction) + - [Filter expression redaction](#filter-expression-redaction) + - [Masking any field](#masking-any-field) + - [Keeping an allow list](#keeping-an-allow-list) + - [Removing fields](#removing-fields) + - [Rewriting matched text](#rewriting-matched-text) + - [Scoping to one branch](#scoping-to-one-branch) - [Composing multiple redactions](#composing-multiple-redactions) - [Custom redaction](#custom-redaction) + [Custom log template](#custom-log-template) + + [Metrics](#metrics) + - [The telemetry logger](#the-telemetry-logger) + - [Emitting a metric](#emitting-a-metric) + - [Refusing unbounded dimensions](#refusing-unbounded-dimensions) + - [Refusing a redacted dimension](#refusing-a-redacted-dimension) + [Testing with the in-memory logger](#testing-with-the-in-memory-logger) * [FAQ](#faq) * [License](#license) @@ -40,13 +49,22 @@ Emits PSR-3 structured logs for PHP, with each entry carrying timestamp, component, correlation id, level, and a structured data payload. Supports pluggable redactions for sensitive fields such as passwords, emails, phone numbers, -and identity documents, plus a severity threshold that drops quieter entries before they are rendered. Built for -consumption by log aggregators and SIEM pipelines in production environments. - -Redaction is composable and split in two. The `Redactions` namespace holds strategies named after the kind of data they -protect, ready to use. Nested under it, `Redactions\Rules` holds the general ones, named after the rule they apply: how -much of a value stays visible, which branch of the payload a redaction reaches, which fields survive at all, and which -patterns are masked wherever they appear. +and identity documents, plus a severity threshold that drops quieter entries before they are rendered. It also emits +business metrics through the same stream, since a metric backend that reads them from a log record needs no second +transport. Built for consumption by log aggregators and SIEM pipelines in production environments. + +Redaction is composable and split by how much the strategy already knows. The `Redactions` namespace holds the whole +concept: the `Redaction` contract, the `Mask` and `Visibility` vocabulary that decides what a masked value looks like, +the strategies named after the kind of data they protect, and `GenericRedaction`, which knows nothing until you point +it at something. The named ones are that same generic redaction with the decisions already made: which fields it +covers, how much of the value survives, which branch of the payload it reaches, and which patterns are masked wherever +they appear. + +Metrics are split the same way, and for the same reason. The `Metrics` namespace holds what a measurement is regardless +of where it is published: `Metric`, the `MetricUnit` vocabulary in UCUM base units, and the `MetricFormat` +seam. Nested under it, a namespace per backend holds the implementation that writes for that one, which today is +`Metrics\CloudWatch` for the Amazon CloudWatch Embedded Metric Format. Nothing above the seam knows which backend reads +it.
@@ -62,7 +80,7 @@ composer require tiny-blocks/logger ### Basic logging -Create a logger with `StructuredLogger::create()` and use the fluent builder to configure it. All PSR-3 log levels are +Create a logger with `StreamLogger::builder()` 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 @@ -70,9 +88,9 @@ supported: `debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, a declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'order-service') ->build(); @@ -87,8 +105,8 @@ Output (default template, written to `STDERR`): ### Correlation tracking -A correlation ID can be attached at creation time or derived later using `withContext`. The original instance is never -mutated. +A correlation ID can be attached at creation time or derived later using `withCorrelation`. The original instance is +never mutated. #### At creation time @@ -97,11 +115,11 @@ mutated. declare(strict_types=1); -use TinyBlocks\Logger\LogContext; -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Correlation; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() - ->withContext(context: LogContext::from(correlationId: 'req-abc-123')) +$logger = StreamLogger::builder() + ->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')) ->withComponent(component: 'payment-service') ->build(); @@ -115,16 +133,16 @@ $logger->info(message: 'payment.started', context: ['amount' => 100.50]); declare(strict_types=1); -use TinyBlocks\Logger\LogContext; -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Correlation; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'payment-service') ->build(); -$contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); +$correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')); -$contextual->info(message: 'payment.started', context: ['amount' => 100.50]); +$correlated->info(message: 'payment.started', context: ['amount' => 100.50]); ``` ### Minimum log level @@ -138,9 +156,9 @@ which writes everything. declare(strict_types=1); use TinyBlocks\Logger\LogLevel; -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'order-service') ->withMinimumLevel(minimumLevel: LogLevel::WARNING) ->build(); @@ -162,68 +180,85 @@ applies each one in the order it was registered. #### Strategy catalog -Strategies live in two namespaces. `Redactions` holds the ready-made ones, each named after the kind of data it -protects. `Rules` holds the general ones, each named after the rule it applies, for whatever the ready-made set does not -cover. +A strategy either knows the kind of data it protects, or it is the generic one, pointed at whatever you name. The first +group is the second one with the decisions already made: which fields are covered, which mask renders them, and how much +of the value survives. `TinyBlocks\Logger\Redactions`, by kind of data: -| Strategy | Keeps visible | Default field | -|---------------------|-----------------------------------|---------------| -| `DocumentRedaction` | Trailing characters | `document` | -| `EmailRedaction` | Local part prefix and full domain | `email` | -| `PhoneRedaction` | Trailing characters | `phone` | -| `NameRedaction` | Leading characters | `name` | -| `PasswordRedaction` | Nothing | `password` | - -`TinyBlocks\Logger\Redactions\Rules`, by rule: - -| Strategy | Keeps visible | Typical use | -|--------------------------|-----------------------------------|---------------------------------------------------------| -| `VisibleEdgesRedaction` | Leading and trailing characters | Any value needing a custom window | -| `WordwiseRedaction` | The edges of every word | Full names, multi word labels | -| `FullMaskRedaction` | Nothing | Secrets, free text, addresses, user agents | -| `ScopedRedaction` | Delegates within one parent field | Field names that repeat with different meanings | -| `AllowedFieldsRedaction` | Only the listed fields | Deny by default, so a new field is masked until allowed | -| `RemovedFieldsRedaction` | Nothing, the field itself is gone | Stack traces, payment codes | -| `PatternRedaction` | Everything except the matches | Sensitive data embedded in free text | +| Strategy | Protects | Keeps visible | +|-----------------------------|-------------------------------------------------|-----------------------------------| +| `DocumentRedaction` | A document number in a `document` field | Trailing characters | +| `EmailRedaction` | An address in an `email` field | Local part prefix and full domain | +| `NameRedaction` | A person or holder name in a `name` field | Leading characters | +| `BirthDateRedaction` | A date of birth, under any of its names | The year, and the date shape | +| `PostalCodeRedaction` | A postal or zip code, under any of its names | The leading characters | +| `PhoneRedaction` | A number in a `phone` field | Trailing characters | +| `SecretRedaction` | Credentials, by the names they travel under | Nothing | +| `QueryStringRedaction` | Whatever rides after the `?` of any URL | The path | +| `QueryParametersRedaction` | The parsed query parameters of a request | Only the parameters named | +| `FilterExpressionRedaction` | The operand of every comparison in a filter | The field and the operator | + +`GenericRedaction`, for everything else: + +| Factory | What it does | +|------------------------------------|--------------------------------------------------------------------------| +| `GenericRedaction::under(...)` | Applies another redaction only under one parent field | +| `GenericRedaction::keeping(...)` | Masks every field except the ones named, with a fixed mask when none is given | +| `GenericRedaction::masking(...)` | Masks the fields named, as much as the `Visibility` says | +| `GenericRedaction::removing(...)` | Drops the fields named, so not even their presence reaches the log | +| `GenericRedaction::replacing(...)` | Rewrites every match of a pattern, in every value, whatever the field is | #### Choosing a mask -The general primitives take a `Mask`, which decides how the hidden portion is rendered. +Every strategy that masks takes a `Mask`, which decides how the hidden portion is rendered. | Factory | Renders | Reveals the original length | |--------------------------------|------------------------------------------------------|-----------------------------| | `Mask::proportional()` | One mask character per hidden character | Yes | -| `Mask::fixed(length: 8)` | The same number of characters every time | No | +| `Mask::fixed()` | The same number of characters every time, eight | No | | `Mask::preservingSeparators()` | Letters and digits masked, everything else preserved | Partially | +The strategies by kind of data use `Mask::proportional()`, so their output has the width of the original value. When +the width itself is sensitive, a password being the clearest case, reach for `Mask::fixed(...)`. + +#### Choosing what stays visible + +`Visibility` is the sibling decision: the `Mask` renders what is hidden, this one decides what is hidden at all. + +| Factory | Keeps visible | +|--------------------------------------------------------|--------------------------------------------------| +| `Visibility::none()` | Nothing | +| `Visibility::edges(prefixLength: 2, suffixLength: 4)` | A window at the head, at the tail, or at both | +| `Visibility::words(prefixLength: 2)` | The same window on every word of the value | +| `Visibility::localPart(prefixLength: 2)` | The head of what precedes the `@`, plus the domain | + ```php withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::default()) ->build(); $logger->info(message: 'kyc.verified', context: ['document' => '12345678900']); -# document → "********900" +# document → "*********00" ``` With custom fields and visible length: @@ -291,10 +333,10 @@ Preserves the first N characters of the local part (default: 2) and the full dom declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; use TinyBlocks\Logger\Redactions\EmailRedaction; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::default()) ->build(); @@ -324,10 +366,10 @@ Masks all characters except the last N (default: 4). declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; use TinyBlocks\Logger\Redactions\PhoneRedaction; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'notification-service') ->withRedactions(PhoneRedaction::default()) ->build(); @@ -348,226 +390,298 @@ use TinyBlocks\Logger\Redactions\PhoneRedaction; PhoneRedaction::from(fields: ['phone', 'mobile', 'whatsapp'], visibleSuffixLength: 4); ``` -#### Password redaction +#### Name redaction -Masks the entire value with a fixed-length mask (default: 8 characters). The original value's length is never revealed -in the output, preventing information leakage about password size. +Preserves the first N characters (default: 2) and masks the rest. ```php withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) +$logger = StreamLogger::builder() + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::default()) ->build(); -$logger->info(message: 'login.attempt', context: ['password' => 's3cr3t!']); -# password → "********" - -$logger->info(message: 'login.attempt', context: ['password' => '123']); -# password → "********" (same mask regardless of length) +$logger->info(message: 'user.created', context: ['name' => 'Gustavo']); +# name → "Gu*****" ``` -With custom fields and fixed mask length: +With custom fields and visible length: ```php withComponent(component: 'user-service') - ->withRedactions(NameRedaction::default()) +$logger = StreamLogger::builder() + ->withComponent(component: 'payment-service') + ->withRedactions(BirthDateRedaction::default()) ->build(); -$logger->info(message: 'user.created', context: ['name' => 'Gustavo']); -# name → "Gu*****" +$logger->info(message: 'payer.registered', context: [ + 'birth_date' => '1990-07-21', + 'birthplace' => 'São Paulo', + 'plan' => 'saas' +]); +# birth_date → "1990-**-**" +# birthplace → "São Paulo" (unchanged, another field entirely) +# plan → "saas" (unchanged) ``` -With custom fields and visible length: +For a date written the other way around, name the window that keeps what you meant to keep: +`BirthDateRedaction::from(fields: ['birth_date'], visiblePrefixLength: 0)` hides the year as well. + +#### Postal code redaction + +A full postal code narrows to a street, and sometimes to a building. The leading characters name a region, which is +what tells one market from another, so the default keeps three of them. The same value travels under several names, so +the default covers `post*code` and `zip*code`, which with the name matching described above reaches `postal_code`, +`postalCode`, `postcode`, `zip_code`, `zipCode`, and `zipcode` alike. A bare `zip` is left out, since a field by that +name is as likely to carry an archive. ```php withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::default()) + ->build(); + +$logger->info(message: 'address.given', context: [ + 'postal_code' => '01310-100', + 'zipCode' => '94103', + 'zip' => 'invoices.zip', + 'area_code' => '11' +]); +# postal_code → "013**-***" +# zipCode → "941**" +# zip → "invoices.zip" (unchanged, a bare zip is not a postal code) +# area_code → "11" (unchanged, another code entirely) ``` -#### Visible edges redaction +Codes of other shapes keep the same rule, since the separators survive: a `SW1A 1AA` comes out as `SW1* ***`. For a +name outside the default set, `PostalCodeRedaction::from(fields: [...])` takes its own list. -The general primitive behind the field-specific strategies. Keeps a window at the head, at the tail, or at both ends. +#### Secret redaction + +Masks credentials entirely, with a fixed mask, so neither the value nor its length reaches the log. The default field +list follows the naming conventions credentials travel under rather than a fixed set of names, which is what covers the +field nobody remembered to declare. ```php withComponent(component: 'payment-service') - ->withRedactions( - VisibleEdgesRedaction::from( - mask: Mask::proportional(), - fields: ['birth_date'], - visiblePrefixLength: 4 - ) - ) +$logger = StreamLogger::builder() + ->withComponent(component: 'auth-service') + ->withRedactions(SecretRedaction::default()) ->build(); -$logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']); -# birth_date → "1990******" +$logger->info(message: 'session.started', context: [ + 'access_token' => 'eyJhbGciOi', + 'client_secret' => 's3cr3t!', + 'authorization' => 'Bearer abc123', + 'role' => 'owner' +]); +# access_token → "********" +# client_secret → "********" +# authorization → "********" +# role → "owner" (unchanged) ``` -A negative visible length is rejected with `NegativeVisibleLength`. +The default covers `*token*`, `*secret*`, `*api_key*`, `*password*`, `*private_key*`, `credentials`, and +`authorization`. Name your own fields, and the mask length, with `SecretRedaction::from(...)`. -#### Wordwise redaction +#### Query string redaction -Masks each word of the value on its own, so a full name stays readable as a shape instead of collapsing into a single -run of mask characters. +A URL reaches the log as an ordinary string, so no field name covers what rides after the question mark: an identifier, +a token, a filter carrying a document. The path is what a reader needs to know which route was called, and it survives. ```php withComponent(component: 'user-service') - ->withRedactions( - WordwiseRedaction::from( - mask: Mask::fixed(length: 3), - fields: ['name', 'holder'], - visiblePrefixLength: 2 - ) - ) +$logger = StreamLogger::builder() + ->withComponent(component: 'client-gateway') + ->withRedactions(QueryStringRedaction::default()) ->build(); -$logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); -# name → "Gu*** Fr***" +$logger->info(message: 'request.received', context: [ + 'uri' => '/v1/clients?document=12345678900&page=2', + 'route' => '/v1/clients', + 'message' => 'GET https://api.example.com/v1/clients?token=abc123 failed' +]); +# uri → "/v1/clients?" +# route → "/v1/clients" (unchanged, there is nothing after a question mark) +# message → "GET https://api.example.com/v1/clients? failed" ``` -#### Full mask redaction - -Masks the value entirely. Suited to anything that carries no operational meaning once logged, such as secrets, free -text, street addresses, and user agents. +#### Query parameters redaction -It is the named counterpart of `VisibleEdgesRedaction` with no visible edge. Both produce the same output, and the -separate name exists so the intent reads at the call site, the same way `DocumentRedaction` and `PhoneRedaction` are -both suffix strategies under two names. +The parsed counterpart of the query string. A parameter carries whatever the caller sent, so naming what stays readable +removes the leak by omission: one added later is masked until it is allowed. It reaches the `query_parameters` branch, +which is where `tiny-blocks/http-logging` puts them. ```php withComponent(component: 'audit-service') - ->withRedactions( - FullMaskRedaction::from(mask: Mask::fixed(length: 8), fields: ['ip', 'user_agent', 'session_id']) - ) +$logger = StreamLogger::builder() + ->withComponent(component: 'ledger') + ->withRedactions(QueryParametersRedaction::keeping(fields: ['sort', 'page*'])) ->build(); -$logger->info(message: 'request.received', context: ['ip' => '10.0.0.1', 'route' => '/v1/users']); -# ip → "********" -# route → "/v1/users" (unchanged) +$logger->info(message: 'request.received', context: [ + 'uri' => '/v1/clients', + 'query_parameters' => ['sort' => 'created_at', 'page_size' => '20', 'document' => '12345678900'], + 'body' => ['document' => '12345678900'] +]); +# query_parameters.sort → "created_at" (allowed) +# query_parameters.page_size → "20" (allowed by the wildcard) +# query_parameters.document → "********" +# body.document → "12345678900" (untouched, another branch) ``` -`commonSecrets()` covers the field names that carry secrets across most systems (`*token*`, `*secret*`, `*api_key*`, -`*password*`, `*private_key*`, `credentials`, and `authorization`) with a fixed mask: +`QueryParametersRedaction::default()` allows none of them, which masks every parameter. For a payload carrying them +under another name, compose `GenericRedaction::under(...)` with `GenericRedaction::keeping(...)`. + +#### Filter expression redaction + +A filter arrives as one string, so the field names inside it are out of reach of any field based strategy, and the +operand is where the sensitive value sits. Written for the comparison syntax RSQL and FIQL share, including the +parenthesized list of an `=in=` comparison. What is asked stays readable, what is asked about does not. ```php withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) + ->build(); -FullMaskRedaction::commonSecrets(); -# access_token → "********" -# client_secret → "********" -# current_password → "********" +$logger->info(message: 'query.received', context: [ + 'equality' => 'status==active;document==12345678900', + 'range' => 'created_at=ge=2026-01-01,created_at=le=2026-02-01', + 'list' => 'document=in=(12345678900,98765432100)', + 'sort' => 'created_at desc' +]); +# equality → "status==********;document==********" +# range → "created_at=ge=********,created_at=le=********" +# list → "document=in=********" +# sort → "created_at desc" (unchanged, nothing is compared) ``` -#### Scoped redaction +#### Masking any field -Field names repeat across a payload with different meanings. A `value` under `document` is an identity document, a -`value` under `metadata` is an operational marker. Scoping a redaction to its parent keeps the first masked and the -second readable. +The generic strategy for a field the library knows nothing about. Name the fields, the mask, and how much survives. ```php withComponent(component: 'payment-service') ->withRedactions( - ScopedRedaction::under( - parent: 'document', - redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) - ) + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['birth_date'], + visibility: Visibility::edges(prefixLength: 4) + ), + GenericRedaction::masking(mask: Mask::fixed(), fields: ['ip', 'user_agent', 'session_id']) ) ->build(); -$logger->info(message: 'payment.created', context: [ - 'document' => ['type' => 'cpf', 'value' => '12345678901'], - 'metadata' => ['value' => 'operational-marker'] +$logger->info(message: 'payer.registered', context: [ + 'birth_date' => '1990-07-21', + 'ip' => '10.0.0.1', + 'route' => '/v1/payers' ]); -# document.value → "*********01" -# metadata.value → "operational-marker" (unchanged) +# birth_date → "1990******" +# ip → "********" +# route → "/v1/payers" (unchanged) ``` -The scope applies at any depth, so a `document` nested under `charge` is covered by the same rule. +With `Visibility::words(...)`, each word is masked on its own, so a full name keeps its shape instead of collapsing +into a single run: -#### Allowed fields redaction +```php +withComponent(component: 'payment-service') ->withRedactions( - ScopedRedaction::under( + GenericRedaction::under( parent: 'address', - redaction: AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city', 'state']) + redaction: GenericRedaction::keeping(fields: ['city', 'state'], mask: Mask::fixed(length: 3)) ) ) ->build(); @@ -599,7 +712,7 @@ $logger->info(message: 'payment.created', context: [ # address.number → "***" ``` -#### Removed fields redaction +#### Removing fields Drops the field instead of masking it. Preferred when the field carries no diagnostic value at all, so nothing about the original reaches the log, not even its presence. @@ -609,12 +722,12 @@ original reaches the log, not even its presence. declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; -use TinyBlocks\Logger\Redactions\Rules\RemovedFieldsRedaction; +use TinyBlocks\Logger\Redactions\GenericRedaction; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'error-service') - ->withRedactions(RemovedFieldsRedaction::from(fields: ['trace', '*_token'])) + ->withRedactions(GenericRedaction::removing(fields: ['trace', '*_token'])) ->build(); $logger->error(message: 'request.failed', context: [ @@ -625,9 +738,9 @@ $logger->error(message: 'request.failed', context: [ # data={"message":"boom","nested":{"id":"7"}} ``` -#### Pattern redaction +#### Rewriting matched text -Field-based strategies cannot reach sensitive data embedded in free text: an exception message quoting a document, a +Field based strategies cannot reach sensitive data embedded in free text: an exception message quoting a document, a stack trace, a URI carrying a query string. Matching on the value instead of the key closes that gap. ```php @@ -635,12 +748,12 @@ stack trace, a URI carrying a query string. Matching on the value instead of the declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; -use TinyBlocks\Logger\Redactions\Rules\PatternRedaction; +use TinyBlocks\Logger\Redactions\GenericRedaction; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'error-service') - ->withRedactions(PatternRedaction::from(pattern: '/\d{11}/', replacement: '[REDACTED]')) + ->withRedactions(GenericRedaction::replacing(pattern: '/\d{11}/', replacement: '[REDACTED]')) ->build(); $logger->error(message: 'request.failed', context: [ @@ -651,8 +764,43 @@ $logger->error(message: 'request.failed', context: [ # uri → "/clients/[REDACTED]" ``` -A pattern the regular expression engine rejects raises `InvalidRedactionPattern` at configuration time, not at the first -log call. +A pattern the regular expression engine rejects raises `MalformedRedactionPattern` at configuration time, not at the +first log call. + +#### Scoping to one branch + +Field names repeat across a payload with different meanings. A `value` under `document` is an identity document, a +`value` under `metadata` is an operational marker. Scoping a redaction to its parent keeps the first masked and the +second readable. + +```php +withComponent(component: 'payment-service') + ->withRedactions( + GenericRedaction::under( + parent: 'document', + redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) + ) + ) + ->build(); + +$logger->info(message: 'payment.created', context: [ + 'document' => ['type' => 'cpf', 'value' => '12345678901'], + 'metadata' => ['value' => 'operational-marker'] +]); +# document.value → "*********01" +# metadata.value → "operational-marker" (unchanged) +``` + +The scope applies at any depth, so a `document` nested under `charge` is covered by the same rule. #### Composing multiple redactions @@ -661,21 +809,21 @@ log call. declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; use TinyBlocks\Logger\Redactions\DocumentRedaction; use TinyBlocks\Logger\Redactions\EmailRedaction; -use TinyBlocks\Logger\Redactions\Rules\FullMaskRedaction; use TinyBlocks\Logger\Redactions\NameRedaction; use TinyBlocks\Logger\Redactions\PhoneRedaction; +use TinyBlocks\Logger\Redactions\SecretRedaction; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'user-service') ->withRedactions( NameRedaction::default(), EmailRedaction::default(), PhoneRedaction::default(), DocumentRedaction::default(), - FullMaskRedaction::commonSecrets() + SecretRedaction::default() ) ->build(); @@ -691,7 +839,7 @@ $logger->info(message: 'user.registered', context: [ # email → "jo**@example.com" # phone → "**********7766" # status → "active" (unchanged) -# document → "********900" +# document → "*********00" # access_token → "********" ``` @@ -704,7 +852,7 @@ Implement the `Redaction` interface to create your own strategy: declare(strict_types=1); -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; final readonly class ReversedRedaction implements Redaction { @@ -733,9 +881,9 @@ Then add it to the logger: declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'auth-service') ->withRedactions(new ReversedRedaction()) ->build(); @@ -760,9 +908,9 @@ correlationId, level, key, data): declare(strict_types=1); -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\StreamLogger; -$logger = StructuredLogger::create() +$logger = StreamLogger::builder() ->withComponent(component: 'custom-service') ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") ->build(); @@ -771,11 +919,182 @@ $logger->info(message: 'custom.event', context: ['value' => 42]); # [2026-02-21T16:00:00+00:00] custom-service | | INFO | custom.event | {"value":42} ``` +### Metrics + +A `Metric` is a measurement expressed without reference to any metric backend: a namespace, a name, a value in a +`MetricUnit`, the dimensions it is broken down by, and the fields that ride along in the same record. `MetricUnit` +carries UCUM symbols and base units only, which is what the neutral specifications prescribe: a duration is `0.5` in +seconds and never `500` in a millisecond unit that does not exist here. A name, a namespace, a dimension name, or a +field name that carries nothing is refused with `BlankMetricIdentifier`, and one name used by a field and by a +dimension at once is refused with `DuplicateMetricIdentifier`, because both land at the root of the same record and one +would silently replace the other. What separates a dimension from a field is cost, not shape. A backend indexes each +distinct set of dimension values as a series of its own and bills it, while a field stays queryable without multiplying +anything, which is where a granular attribute belongs. + +Rendering is a `MetricFormat` concern, and each implementation lives in the folder of the backend it writes for. +`Metrics\CloudWatch\EmbeddedMetricFormat` is the one for the Amazon CloudWatch Embedded Metric Format, which is a +CloudWatch specification and not a neutral one, so the namespace says whose it is. EMF publishes a metric by writing a +log record that carries the metric inside it, which spares the publish API call but never the custom metric tariff. The +`_aws` envelope and the spelling of every unit belong to that implementation alone: `Metric` and `MetricUnit` carry +neither, so a second format is a new class and not a change to what emits. + +#### The telemetry logger + +`TelemetryLogger` is the logger that measures as well as writes. It is built once around the format that renders every +metric, and from there `metric()` sits next to the PSR-3 methods: + +```php +withComponent(component: 'schedule') + ->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')) + ->build(); + +$logger->info(message: 'appointment.created', context: ['appointment_id' => 42]); +$logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule')); +``` + +Two records reach the stream, one per line, and they never share one: + +``` +2026-02-21T16:00:00+00:00 component=schedule correlation_id=req-abc-123 level=INFO key=appointment.created +data={"appointment_id":42} +{"component":"schedule","correlation_id":"req-abc-123","AppointmentCreated":1,"_aws":{"Timestamp":1771891200000, +"CloudWatchMetrics":[{"Namespace":"Acme/Schedule","Dimensions":[[]],"Metrics":[{"Name":"AppointmentCreated", +"Unit":"Count"}]}]}} +``` + +An entry is narrative and carries a severity; a metric is a measurement and carries none, so a threshold raised to +quiet the logs does not stop a series. The logger hangs `component` and `correlation_id` on the metric by itself, +which is what lets a series be read back against the entries around it. Every redaction configured on the builder +reaches both records. Build a [`StreamLogger`](#basic-logging) instead when nothing is measured: there the method does +not exist on the type. See [FAQ 05](#faq) for why the two records are two lines. + +#### Emitting a metric + +A metric is a counter of one until it is told otherwise. Fields ride along in the record, dimensions break the series +down: + +```php +withComponent(component: 'schedule') + ->build(); + +$metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist') + ->withField(name: 'tenant_id', value: 'abc-123') + ->withDimension(name: 'plan', value: 'saas'); + +$logger->metric(metric: $metric); +# {"tenant_id":"abc-123","component":"schedule","correlation_id":"","plan":"saas","OfferAccepted":1, +# "_aws":{"Timestamp":1771891200000,"CloudWatchMetrics":[{"Namespace":"Acme/Waitlist","Dimensions":[["plan"]], +# "Metrics":[{"Name":"OfferAccepted","Unit":"Count"}]}]}} +``` + +A measurement that is not a count derives from the same factory: + +```php +withComponent(component: 'schedule') + ->build(); + +$metric = Metric::of(name: 'OfferResponseTime', namespace: 'Acme/Waitlist') + ->withUnit(unit: MetricUnit::SECONDS) + ->withValue(value: 12.5); + +$logger->metric(metric: $metric); +# {"component":"schedule","correlation_id":"","OfferResponseTime":12.5,"_aws":{"Timestamp":1771891200000, +# "CloudWatchMetrics":[{"Namespace":"Acme/Waitlist","Dimensions":[[]],"Metrics":[{"Name":"OfferResponseTime", +# "Unit":"Seconds"}]}]}} +``` + +#### Refusing unbounded dimensions + +Which dimension names take an unbounded number of values is a property of the emitting domain, so the format refuses +none until it is told. A name declared unbounded is refused with `UnboundedDimension`, and the message points at the +field that carries the same value without creating a series: + +```php +withUnboundedDimensions('tenant_id', 'user_id'); + +$logger = TelemetryLogger::builder(format: $format) + ->withComponent(component: 'schedule') + ->build(); + +$metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist') + ->withDimension(name: 'tenant_id', value: 'abc-123'); + +$logger->metric(metric: $metric); +# UnboundedDimension: The dimension was declared unbounded. Emit it as a field of the same record instead. +``` + +#### Refusing a redacted dimension + +A field covered by a redaction reaches the metric line masked, exactly as it reaches the log line. A dimension cannot +be masked the same way and still be a dimension: its value becomes part of the series identity in the backend index, +where no redaction and no log retention reach it. Declaring one under a name a redaction covers is refused with +`RedactedDimension`: + +```php +withComponent(component: 'identity') + ->withRedactions(DocumentRedaction::default()) + ->build(); + +$metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity') + ->withDimension(name: 'document', value: '12345678900'); + +$logger->metric(metric: $metric); +# RedactedDimension: The dimension is covered by a redaction. A dimension value reaches the metric index +# of the backend, where no redaction and no log retention can reach it. Emit it as a field instead. +``` + ### Testing with the in-memory logger `InMemoryLogger` records entries instead of writing them, so a test asserts on what was logged rather than on how it was rendered. Payloads are kept exactly as received, with no redaction and no formatting. Loggers derived through -`withContext` record into the same store, so entries are visible from either instance. +`withCorrelation` record into the same store, so entries are visible from either instance. ```php info(message: 'user.created', context: ['document' => '12345678900']); $logger->entries()->toArray(); -# [['key' => 'user.created', 'level' => 'INFO', 'context' => null, 'payload' => ['document' => '12345678900']]] +# [['key' => 'user.created', 'level' => 'INFO', 'payload' => ['document' => '12345678900'], 'correlation' => null]] ```
@@ -798,12 +1117,12 @@ $logger->entries()->toArray(); ### 01. Why does a masked value keep the width of the original? -Only when the strategy asks for it. The field-specific strategies use `Mask::proportional()`, which emits one character -per hidden character, because a support engineer reading `**********7766` can still tell a mobile number from a short -extension. That convenience costs information: the width of the output is the width of the input. +Only when the strategy asks for it. The strategies by kind of data use `Mask::proportional()`, which emits one +character per hidden character, because a support engineer reading `**********7766` can still tell a mobile number from +a short extension. That convenience costs information: the width of the output is the width of the input. When the width itself is sensitive, a password being the clearest case, use `Mask::fixed(...)`, which emits the same run -every time and is what `PasswordRedaction` and `FullMaskRedaction::commonSecrets()` already do. +every time and is what `SecretRedaction` already does. ### 02. Why scope a redaction to a parent field instead of listing field names? @@ -811,13 +1130,13 @@ Because field names are not unique. A payload carries `code` as a verification c in another, `number` as a card number in one place and as a page number in a query string in another. A flat list of field names cannot tell them apart, so masking the sensitive one also destroys the diagnostic one. -`ScopedRedaction` restricts a strategy to the sub payloads found under a given parent, which is the smallest piece of +`GenericRedaction::under(...)` restricts a strategy to the sub payloads under a given parent, the smallest piece of context needed to disambiguate. ### 03. When should a field be dropped instead of masked? When the masked value would still be noise. A stack trace, a payment brcode, or a raw user agent tells a reader nothing -once masked, and it still costs bytes in every aggregator downstream. `RemovedFieldsRedaction` removes the key entirely. +once masked, and it still costs bytes in every aggregator downstream. `GenericRedaction::removing(...)` drops the key. Masking stays the right answer whenever the shape of the value carries meaning, for example knowing that a document was present and ended in `900`. @@ -828,8 +1147,33 @@ Because leaks happen by omission, not by mistake. A payload gains a `client_name covered, and nothing in the configuration reacts. Patterns such as `*_name` or `*token*` follow the naming convention rather than the current field list. -For payloads where even that is not enough, `AllowedFieldsRedaction` inverts the default: everything is masked until it -is named. +For payloads where even that is not enough, `GenericRedaction::keeping(...)` inverts the default: everything is masked +until it is named. + +### 05. Why do metrics need a logger of their own? + +They do not need a logger of their own. They need a record of their own, and `TelemetryLogger` writes both on the same +stream without mixing them. + +The reason is the shape of the line. An EMF record has to reach the agent as a JSON object at the root, and an entry +has to reach a human with the timestamp, the component, the correlation id, the level and the key in front of it. Fold +one into the other and both stop working: + +``` +# a metric folded into an entry: the _aws key is no longer at the root, so the agent reads no metric +2026-02-21T16:00:00+00:00 component=schedule correlation_id=req-abc-123 level=INFO key=metric +data={"AppointmentCompleted":1,"_aws":{...}} + +# an entry rendered as a bare payload, which is what a metric line looks like: nothing says what happened, or when +{"appointment_id":"abc-123"} +``` + +So the logger writes two lines, and what stitches them back together is what it hangs on the metric by itself: the +`component` and the `correlation_id` of the entries around it. EMF ignores a root key it does not know, and Logs +Insights can still query it. + +The severity threshold follows the same split. It is a property of entries, so raising it to quiet the logs never stops +a series: a measurement carries no severity to compare against.
diff --git a/composer.json b/composer.json index 5f5e0ba..e52f252 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,16 @@ { "name": "tiny-blocks/logger", - "description": "Emits PSR-3 structured logs for PHP, with correlation tracking, a severity threshold, and configurable redaction.", + "description": "Emits PSR-3 structured logs for PHP, with correlation tracking, a severity threshold, configurable redaction, and backend-neutral metrics.", "license": "MIT", "type": "library", "keywords": [ + "emf", "psr-3", "logger", + "metrics", "logging", "redaction", + "cloudwatch", "tiny-blocks" ], "authors": [ diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4824cc7..11ce17e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -13,6 +13,8 @@ parameters: # cannot annotate the value type, and PHPDoc is prohibited on Internal types and constructors. - identifier: missingType.iterableValue path: src/Internal/LogFormatter.php + - identifier: missingType.iterableValue + path: src/Internal/EncodedPayload.php - identifier: missingType.iterableValue path: src/Internal/LogEntryRecorder.php - identifier: missingType.iterableValue @@ -21,8 +23,15 @@ parameters: path: src/LogEntry.php - identifier: missingType.iterableValue path: src/Redactions/*.php + # The builders describe the redactions as a plain list of Redaction, and the loggers hand that + # list to the typed collection when they build. PHPDoc is prohibited on constructors, and the + # element type cannot be written on a promoted property, so it is lost at both crossings. - identifier: missingType.iterableValue - path: src/StructuredLoggerBuilder.php + path: src/StreamLoggerBuilder.php + - identifier: argument.type + path: src/StreamLogger.php + - identifier: argument.type + path: src/TelemetryLogger.php # The redactors traverse plain arrays into the array contract of # Redaction::redact, and the field matcher reads plain string patterns out of an untyped # configuration array; the values originate as mixed log context, an irreducible boundary @@ -36,33 +45,71 @@ parameters: path: src/Internal/LogEntryRecorder.php - identifier: argument.type path: src/Redactions/*.php - # The field matcher indexes its exact field names by value to answer in constant time. Those - # names come from the same untyped configuration array, so the language cannot prove the key - # type at that point. - - identifier: offsetAccess.invalidOffset + # The field matcher reads its field names out of the same untyped configuration array and casts + # each one to string before comparing, so the language cannot prove the type at that point. + - identifier: cast.string path: src/Internal/Redactor/FieldMatcher.php # LogStream wraps a stream resource the language can only promote as mixed; fwrite then # receives that mixed handle. Internal collaborator with intrinsic resource state. - identifier: argument.type path: src/Internal/Stream/LogStream.php - # The builder accumulates Redaction instances in a variadic pass-through array and spreads - # them into StructuredLogger::from(); the element type cannot be annotated on the promoted - # constructor parameter without prohibited PHPDoc. + # The metric carries its dimensions and fields as plain arrays, and the Internal collaborators + # that render them read those shapes back. PHPDoc is prohibited on constructors and on Internal + # types, and neither shape is generic, so the value type cannot be written anywhere. + - identifier: missingType.iterableValue + path: src/Metrics/Metric.php + - identifier: missingType.iterableValue + path: src/Internal/Metrics/*.php + # UnboundedDimensions holds the refused names in a promoted array and looks each one up among + # the dimension keys. The names reach it as strings through a variadic, but the element type is + # lost on the promoted parameter, which is the same boundary the metric line crosses. - identifier: argument.type - path: src/StructuredLoggerBuilder.php - # json_decode() returns mixed, so the deep recursive redaction test array-accesses a mixed - # value when reading the decoded payload. Descriptive PHPDoc is prohibited in tests/, so the - # irreducible mixed-origin offset access is suppressed for this file only. + path: src/Internal/Metrics/*.php + # UnboundedDimensions hands back one of the names it holds, which reached it as a string through a + # variadic. The promoted array lost the element type, and a cast to restore it would be a line no + # test can kill, since every name in there is already a string. + - identifier: return.type + path: src/Internal/Metrics/*.php + # MetricLine reads the Field values back out of the plain array the metric keeps them in, and + # feeds them to the redactions and back. The element type is gone on both crossings. + - identifier: property.nonObject + path: src/Internal/Metrics/*.php + # EmbeddedMetricFormat::format() hands back what the envelope built. MetricFormat declares the + # payload shape and the interface docblock is where it belongs; the value type is lost crossing + # the Internal collaborator, which cannot carry PHPDoc to restore it. + - identifier: return.type + path: src/Metrics/CloudWatch/*.php + # The metric data providers return rows of arguments and the record fixture navigates a payload + # whose nested shape the language cannot see. PHPDoc is prohibited anywhere inside tests/. + - identifier: missingType.iterableValue + path: tests/Unit/EmbeddedMetricFormatTest.php + - identifier: missingType.iterableValue + path: tests/Unit/MetricTest.php + # The test reads a Dimension and a Field back out of the plain arrays the metric keeps them in, + # so the element type is gone by the time the assertion reaches the value object. + - identifier: property.nonObject + path: tests/Unit/MetricTest.php + - identifier: missingType.iterableValue + path: tests/Models/EmbeddedMetricPayload.php - identifier: offsetAccess.nonOffsetAccessible - path: tests/StructuredLoggerTest.php + path: tests/Models/EmbeddedMetricPayload.php + # json_decode() returns mixed, so the deep recursive redaction test and the metric line tests + # array-access a mixed value when reading the decoded payload. Descriptive PHPDoc is prohibited + # in tests/, so the irreducible mixed-origin offset access is suppressed for those files. + - identifier: offsetAccess.nonOffsetAccessible + path: tests/Unit/StreamLoggerTest.php + - identifier: offsetAccess.nonOffsetAccessible + path: tests/Unit/TelemetryLoggerTest.php # The mixed values read from that decoded payload feed string-typed PHPUnit assertion # parameters (assertStringStartsWith, assertStringContainsString, ...). Same mixed origin. - identifier: argument.type - path: tests/StructuredLoggerTest.php + path: tests/Unit/StreamLoggerTest.php + - identifier: argument.type + path: tests/Unit/TelemetryLoggerTest.php # The data providers return lists of argument rows. Their iterable value type cannot be # expressed without PHPDoc, which tests/ forbids, so it is suppressed for those files. - identifier: missingType.iterableValue - path: tests/LogLevelTest.php + path: tests/Unit/LogLevelTest.php - identifier: missingType.iterableValue - path: tests/StructuredLoggerTest.php + path: tests/Unit/StreamLoggerTest.php reportUnmatchedIgnoredErrors: true diff --git a/src/LogContext.php b/src/Correlation.php similarity index 55% rename from src/LogContext.php rename to src/Correlation.php index 6e5999c..af7a045 100644 --- a/src/LogContext.php +++ b/src/Correlation.php @@ -7,20 +7,20 @@ /** * Correlation context carried across log entries. */ -final readonly class LogContext +final readonly class Correlation { private function __construct(public string $correlationId) { } /** - * Creates a LogContext from a correlation identifier. + * Creates a Correlation from a correlation identifier. * * @param string $correlationId The correlation identifier shared across related log entries. - * @return LogContext The created instance. + * @return Correlation The created instance. */ - public static function from(string $correlationId): LogContext + public static function from(string $correlationId): Correlation { - return new LogContext(correlationId: $correlationId); + return new Correlation(correlationId: $correlationId); } } diff --git a/src/Exceptions/BlankMetricIdentifier.php b/src/Exceptions/BlankMetricIdentifier.php new file mode 100644 index 0000000..db4eb00 --- /dev/null +++ b/src/Exceptions/BlankMetricIdentifier.php @@ -0,0 +1,17 @@ +identifier) + ); + } +} diff --git a/src/Exceptions/DuplicateMetricIdentifier.php b/src/Exceptions/DuplicateMetricIdentifier.php new file mode 100644 index 0000000..a12533a --- /dev/null +++ b/src/Exceptions/DuplicateMetricIdentifier.php @@ -0,0 +1,21 @@ + is carried by a field and by a dimension at once. Both land at the root of the ' + . 'same record, so one would silently replace the other.', + $this->identifier + ) + ); + } +} diff --git a/src/Exceptions/InvalidRedactionPattern.php b/src/Exceptions/MalformedRedactionPattern.php similarity index 74% rename from src/Exceptions/InvalidRedactionPattern.php rename to src/Exceptions/MalformedRedactionPattern.php index a091afd..e2e0b1f 100644 --- a/src/Exceptions/InvalidRedactionPattern.php +++ b/src/Exceptions/MalformedRedactionPattern.php @@ -9,6 +9,6 @@ /** * Raised when a redaction is configured with a pattern the regular expression engine rejects. */ -final class InvalidRedactionPattern extends InvalidArgumentException +final class MalformedRedactionPattern extends InvalidArgumentException { } diff --git a/src/Exceptions/RedactedDimension.php b/src/Exceptions/RedactedDimension.php new file mode 100644 index 0000000..1ecd056 --- /dev/null +++ b/src/Exceptions/RedactedDimension.php @@ -0,0 +1,21 @@ + is covered by a redaction. A dimension value reaches the metric index of ' + . 'the backend, where no redaction and no log retention can reach it. Emit it as a field instead.', + $this->dimension + ) + ); + } +} diff --git a/src/Exceptions/UnboundedDimension.php b/src/Exceptions/UnboundedDimension.php new file mode 100644 index 0000000..07f35ca --- /dev/null +++ b/src/Exceptions/UnboundedDimension.php @@ -0,0 +1,20 @@ + was declared unbounded. Emit it as a field of the same record instead.', + $this->dimension + ) + ); + } +} diff --git a/src/InMemoryLogger.php b/src/InMemoryLogger.php index 582ea1f..e46445f 100644 --- a/src/InMemoryLogger.php +++ b/src/InMemoryLogger.php @@ -16,25 +16,25 @@ * what was logged rather than how it was rendered. Payloads are recorded exactly as received, with * no redaction and no formatting, so assertions read the original values.

* - *

Loggers derived through {@see withContext} record into the same store as the instance they + *

Loggers derived through {@see withCorrelation} record into the same store as the instance they * come from, so entries logged through a derived instance are visible from either one.

*/ final readonly class InMemoryLogger implements Logger { use LoggerTrait; - private function __construct(private ?LogContext $context, private LogEntryRecorder $recorder) + private function __construct(private LogEntryRecorder $recorder, private ?Correlation $correlation) { } /** - * Creates an InMemoryLogger with no bound context and no recorded entries. + * Creates an InMemoryLogger with no bound correlation and no recorded entries. * * @return InMemoryLogger The created instance. */ public static function create(): InMemoryLogger { - return new InMemoryLogger(context: null, recorder: new LogEntryRecorder()); + return new InMemoryLogger(recorder: new LogEntryRecorder(), correlation: null); } /** @@ -48,11 +48,11 @@ public static function create(): InMemoryLogger public function log(mixed $level, string|Stringable $message, array $context = []): void { $this->recorder->record( - entry: LogEntry::from( + entry: LogEntry::of( key: (string)$message, level: LogLevel::fromPsrLevel(level: $level), - context: $this->context, - payload: $context + payload: $context, + correlation: $this->correlation ) ); } @@ -67,8 +67,8 @@ public function entries(): LogEntries return $this->recorder->toLogEntries(); } - public function withContext(LogContext $context): InMemoryLogger + public function withCorrelation(Correlation $correlation): InMemoryLogger { - return new InMemoryLogger(context: $context, recorder: $this->recorder); + return new InMemoryLogger(recorder: $this->recorder, correlation: $correlation); } } diff --git a/src/Internal/EncodedPayload.php b/src/Internal/EncodedPayload.php new file mode 100644 index 0000000..035dd14 --- /dev/null +++ b/src/Internal/EncodedPayload.php @@ -0,0 +1,33 @@ +payload, + (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) + ); + } catch (JsonException) { + return self::ENCODING_FAILURE; + } + } +} diff --git a/src/Internal/LogFormatter.php b/src/Internal/LogFormatter.php index 69650ea..839c983 100644 --- a/src/Internal/LogFormatter.php +++ b/src/Internal/LogFormatter.php @@ -6,15 +6,13 @@ use DateTimeImmutable; use DateTimeInterface; -use JsonException; -use TinyBlocks\Logger\LogContext; +use TinyBlocks\Logger\Correlation; use TinyBlocks\Logger\LogLevel; final readonly class LogFormatter { private const string DEFAULT_TEMPLATE = "%s component=%s correlation_id=%s level=%s key=%s data=%s\n"; private const string EMPTY_CORRELATION_ID = ''; - private const string ENCODING_FAILURE_PAYLOAD = '{"error":"encoding_failed"}'; private function __construct(private string $template, private string $component) { @@ -35,19 +33,10 @@ public static function fromComponent(string $component): LogFormatter return new LogFormatter(template: self::DEFAULT_TEMPLATE, component: $component); } - public function format(string $key, LogLevel $level, array $payload, ?LogContext $context = null): string + public function format(string $key, LogLevel $level, array $payload, ?Correlation $correlation = null): string { $timestamp = new DateTimeImmutable()->format(DateTimeInterface::ATOM); - $correlationId = is_null($context) ? self::EMPTY_CORRELATION_ID : $context->correlationId; - - try { - $encodedData = json_encode( - $payload, - (JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) - ); - } catch (JsonException) { - $encodedData = self::ENCODING_FAILURE_PAYLOAD; - } + $correlationId = is_null($correlation) ? self::EMPTY_CORRELATION_ID : $correlation->correlationId; return sprintf( $this->template, @@ -56,7 +45,7 @@ public function format(string $key, LogLevel $level, array $payload, ?LogContext LogFormatter::sanitize(value: $correlationId), $level->value, LogFormatter::sanitize(value: $key), - $encodedData + EncodedPayload::from(payload: $payload)->toString() ); } } diff --git a/src/Internal/Metrics/CloudWatchEnvelope.php b/src/Internal/Metrics/CloudWatchEnvelope.php new file mode 100644 index 0000000..485193e --- /dev/null +++ b/src/Internal/Metrics/CloudWatchEnvelope.php @@ -0,0 +1,50 @@ +metric->name->value; + $timestamp = (new DateTimeImmutable()->getTimestamp() * self::MILLISECONDS_PER_SECOND); + $fields = array_map(static fn(MetricField $field): string|int|float => $field->value, $this->metric->fields); + $dimensions = array_map( + static fn(MetricDimension $dimension): string => $dimension->value, + $this->metric->dimensions + ); + + return array_merge( + $fields, + $dimensions, + [$name => $this->metric->value], + [ + '_aws' => [ + 'Timestamp' => $timestamp, + 'CloudWatchMetrics' => [ + [ + 'Namespace' => $this->metric->namespace->value, + 'Dimensions' => [array_keys($dimensions)], + 'Metrics' => [ + ['Name' => $name, 'Unit' => CloudWatchUnit::from(unit: $this->metric->unit)] + ] + ] + ] + ] + ] + ); + } +} diff --git a/src/Internal/Metrics/CloudWatchUnit.php b/src/Internal/Metrics/CloudWatchUnit.php new file mode 100644 index 0000000..2f03c73 --- /dev/null +++ b/src/Internal/Metrics/CloudWatchUnit.php @@ -0,0 +1,24 @@ + 'Bits', + '1' => 'None', + 'By' => 'Bytes', + '{count}' => 'Count', + '%' => 'Percent', + 's' => 'Seconds' + ]; + + public static function from(MetricUnit $unit): string + { + return self::TOKENS[$unit->value]; + } +} diff --git a/src/Internal/Metrics/MetricLine.php b/src/Internal/Metrics/MetricLine.php new file mode 100644 index 0000000..a23ea76 --- /dev/null +++ b/src/Internal/Metrics/MetricLine.php @@ -0,0 +1,61 @@ +fields as $name => $field) { + $values[$name] = $field->value; + } + + $dimensions = []; + + foreach ($metric->dimensions as $name => $dimension) { + $dimensions[$name] = $dimension->value; + } + + $sensitive = array_diff_assoc($dimensions, $redactions->applyTo(payload: $dimensions)); + + foreach (array_keys($sensitive) as $name) { + throw new RedactedDimension(dimension: (string)$name); + } + + $carried = $metric->withoutFields(); + + foreach ($redactions->applyTo(payload: $values) as $name => $value) { + $carried = $carried->withField(name: (string)$name, value: $value); + } + + $identified = $carried + ->withField(name: 'component', value: $component) + ->withField(name: 'correlation_id', value: $correlationId); + + return new MetricLine(payload: $format->format(metric: $identified)); + } + + public function toString(): string + { + return sprintf('%s%s', EncodedPayload::from(payload: $this->payload)->toString(), PHP_EOL); + } +} diff --git a/src/Internal/Metrics/MetricWriter.php b/src/Internal/Metrics/MetricWriter.php new file mode 100644 index 0000000..200573f --- /dev/null +++ b/src/Internal/Metrics/MetricWriter.php @@ -0,0 +1,35 @@ +format, + metric: $metric, + component: $this->component, + redactions: $this->redactions, + correlationId: ($correlation->correlationId ?? '') + ); + + $this->stream->write(content: $line->toString()); + } +} diff --git a/src/Internal/Metrics/UnboundedDimensions.php b/src/Internal/Metrics/UnboundedDimensions.php new file mode 100644 index 0000000..c1c93b5 --- /dev/null +++ b/src/Internal/Metrics/UnboundedDimensions.php @@ -0,0 +1,30 @@ +names, $names)); + } + + public function firstIn(array $dimensions): ?string + { + return array_find( + $this->names, + static fn(mixed $name): bool => array_key_exists($name, $dimensions) + ); + } +} diff --git a/src/Internal/Redactor/FieldMatcher.php b/src/Internal/Redactor/FieldMatcher.php index 7d795a2..bda43e6 100644 --- a/src/Internal/Redactor/FieldMatcher.php +++ b/src/Internal/Redactor/FieldMatcher.php @@ -18,21 +18,31 @@ public function __construct(array $fields) $exactFields = []; foreach ($fields as $field) { - if (strpbrk($field, self::WILDCARD_CHARACTERS) === false) { - $exactFields[$field] = $field; + $normalized = FieldMatcher::normalized(value: (string)$field); + + if (strpbrk($normalized, self::WILDCARD_CHARACTERS) === false) { + $exactFields[$normalized] = $normalized; continue; } - $patterns[] = $field; + $patterns[] = $normalized; } $this->patterns = $patterns; $this->exactFields = $exactFields; } + private static function normalized(string $value): string + { + $separated = preg_replace('/([A-Z]+)([A-Z][a-z])/', '$1_$2', $value); + $split = preg_replace('/([a-z\d])([A-Z])/', '$1_$2', (string)$separated); + + return strtolower((string)$split); + } + public function matches(int|string $key): bool { - $candidate = (string)$key; + $candidate = FieldMatcher::normalized(value: (string)$key); return isset($this->exactFields[$candidate]) || ($this->patterns !== [] diff --git a/src/Internal/Redactor/FieldRemover.php b/src/Internal/Redactor/FieldRemover.php index 1d813b9..14f1836 100644 --- a/src/Internal/Redactor/FieldRemover.php +++ b/src/Internal/Redactor/FieldRemover.php @@ -4,7 +4,7 @@ namespace TinyBlocks\Logger\Internal\Redactor; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; final readonly class FieldRemover implements Redaction { diff --git a/src/Internal/Redactor/PatternRedactor.php b/src/Internal/Redactor/PatternRedactor.php index a670036..9cf15bf 100644 --- a/src/Internal/Redactor/PatternRedactor.php +++ b/src/Internal/Redactor/PatternRedactor.php @@ -4,8 +4,8 @@ namespace TinyBlocks\Logger\Internal\Redactor; -use TinyBlocks\Logger\Exceptions\InvalidRedactionPattern; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Exceptions\MalformedRedactionPattern; +use TinyBlocks\Logger\Redactions\Redaction; final readonly class PatternRedactor implements Redaction { @@ -14,7 +14,7 @@ public function __construct(private string $pattern, private string $replacement if (@preg_match($pattern, '') === false) { $template = 'Pattern is not a valid regular expression: %s.'; - throw new InvalidRedactionPattern(message: sprintf($template, $pattern)); + throw new MalformedRedactionPattern(message: sprintf($template, $pattern)); } } diff --git a/src/Internal/Redactor/Redactions.php b/src/Internal/Redactor/Redactions.php index 47100af..9722f61 100644 --- a/src/Internal/Redactor/Redactions.php +++ b/src/Internal/Redactor/Redactions.php @@ -5,7 +5,7 @@ namespace TinyBlocks\Logger\Internal\Redactor; use TinyBlocks\Collection\Collection; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; /** * @extends Collection diff --git a/src/Internal/Redactor/Redactor.php b/src/Internal/Redactor/Redactor.php index bc59fa1..331093f 100644 --- a/src/Internal/Redactor/Redactor.php +++ b/src/Internal/Redactor/Redactor.php @@ -5,7 +5,7 @@ namespace TinyBlocks\Logger\Internal\Redactor; use Closure; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; final readonly class Redactor implements Redaction { diff --git a/src/Internal/Redactor/ScopedRedactor.php b/src/Internal/Redactor/ScopedRedactor.php index 551245b..4a69d38 100644 --- a/src/Internal/Redactor/ScopedRedactor.php +++ b/src/Internal/Redactor/ScopedRedactor.php @@ -4,7 +4,7 @@ namespace TinyBlocks\Logger\Internal\Redactor; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; final readonly class ScopedRedactor implements Redaction { diff --git a/src/Internal/Redactor/VisibleEdges.php b/src/Internal/Redactor/VisibleEdges.php deleted file mode 100644 index 98ca1d5..0000000 --- a/src/Internal/Redactor/VisibleEdges.php +++ /dev/null @@ -1,35 +0,0 @@ -prefixLength - $this->suffixLength)); - $hidden = mb_substr($value, $this->prefixLength, $hiddenLength, 'UTF-8'); - $template = '%s%s%s'; - - return sprintf( - $template, - mb_substr($value, 0, $this->prefixLength, 'UTF-8'), - $this->mask->applyTo(hidden: $hidden), - mb_substr($value, ($this->prefixLength + $hiddenLength), null, 'UTF-8') - ); - } -} diff --git a/src/Internal/Redactor/RetainedFieldRedactor.php b/src/Internal/Redactor/VisibleFieldRedactor.php similarity index 87% rename from src/Internal/Redactor/RetainedFieldRedactor.php rename to src/Internal/Redactor/VisibleFieldRedactor.php index c5f4a28..9a94a19 100644 --- a/src/Internal/Redactor/RetainedFieldRedactor.php +++ b/src/Internal/Redactor/VisibleFieldRedactor.php @@ -5,9 +5,9 @@ namespace TinyBlocks\Logger\Internal\Redactor; use Closure; -use TinyBlocks\Logger\Redaction; +use TinyBlocks\Logger\Redactions\Redaction; -final readonly class RetainedFieldRedactor implements Redaction +final readonly class VisibleFieldRedactor implements Redaction { public function __construct(private FieldMatcher $fields, private Closure $maskingFunction) { diff --git a/src/Internal/Redactor/VisibleLocalPart.php b/src/Internal/Redactor/VisibleLocalPart.php deleted file mode 100644 index 8796e93..0000000 --- a/src/Internal/Redactor/VisibleLocalPart.php +++ /dev/null @@ -1,31 +0,0 @@ -mask->applyTo(hidden: $value); - } - - $template = '%s%s'; - - return sprintf( - $template, - $this->localPart->applyTo(value: mb_substr($value, 0, $atPosition, 'UTF-8')), - mb_substr($value, $atPosition, null, 'UTF-8') - ); - } -} diff --git a/src/Internal/Redactor/VisibleShape.php b/src/Internal/Redactor/VisibleShape.php new file mode 100644 index 0000000..fb3bf0f --- /dev/null +++ b/src/Internal/Redactor/VisibleShape.php @@ -0,0 +1,92 @@ + VisibleShape::edges( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + VisibleShape::WORDS => VisibleShape::words( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + VisibleShape::LOCAL_PART => VisibleShape::localPart( + mask: $mask, + value: $value, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ) + }; + } + + private static function edges(Mask $mask, string $value, int $prefixLength, int $suffixLength): string + { + $totalLength = mb_strlen($value, 'UTF-8'); + $hiddenLength = max(0, ($totalLength - $prefixLength - $suffixLength)); + $hidden = mb_substr($value, $prefixLength, $hiddenLength, 'UTF-8'); + $template = '%s%s%s'; + + return sprintf( + $template, + mb_substr($value, 0, $prefixLength, 'UTF-8'), + $mask->applyTo(hidden: $hidden), + mb_substr($value, ($prefixLength + $hiddenLength), null, 'UTF-8') + ); + } + + private static function words(Mask $mask, string $value, int $prefixLength, int $suffixLength): string + { + $words = preg_split('/\s+/u', $value, -1, PREG_SPLIT_NO_EMPTY) ?: []; + + $masked = array_map( + static fn(string $word): string => VisibleShape::edges( + mask: $mask, + value: $word, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + $words + ); + + return implode(' ', $masked); + } + + private static function localPart(Mask $mask, string $value, int $prefixLength, int $suffixLength): string + { + $atPosition = mb_strpos($value, '@', 0, 'UTF-8'); + + if ($atPosition === false) { + return $mask->applyTo(hidden: $value); + } + + $template = '%s%s'; + + return sprintf( + $template, + VisibleShape::edges( + mask: $mask, + value: mb_substr($value, 0, $atPosition, 'UTF-8'), + prefixLength: $prefixLength, + suffixLength: $suffixLength + ), + mb_substr($value, $atPosition, null, 'UTF-8') + ); + } +} diff --git a/src/Internal/Redactor/VisibleWords.php b/src/Internal/Redactor/VisibleWords.php deleted file mode 100644 index 0502c3e..0000000 --- a/src/Internal/Redactor/VisibleWords.php +++ /dev/null @@ -1,19 +0,0 @@ -edges->applyTo(...), $words)); - } -} diff --git a/src/LogEntry.php b/src/LogEntry.php index 279e6d8..aca6e6a 100644 --- a/src/LogEntry.php +++ b/src/LogEntry.php @@ -12,22 +12,22 @@ private function __construct( public string $key, public LogLevel $level, - public ?LogContext $context, - public array $payload + public array $payload, + public ?Correlation $correlation ) { } /** - * Creates a LogEntry from its key, level, correlation context, and payload. + * Creates a LogEntry from its key, level, payload, and correlation. * * @param string $key The message key identifying the event. * @param LogLevel $level The severity the entry was logged at. - * @param LogContext|null $context The correlation context bound to the logger, or null when none is. * @param array $payload The context data carried by the entry. + * @param Correlation|null $correlation The correlation bound to the logger, or null when none is. * @return LogEntry The created instance. */ - public static function from(string $key, LogLevel $level, ?LogContext $context, array $payload): LogEntry + public static function of(string $key, LogLevel $level, array $payload, ?Correlation $correlation): LogEntry { - return new LogEntry(key: $key, level: $level, context: $context, payload: $payload); + return new LogEntry(key: $key, level: $level, payload: $payload, correlation: $correlation); } } diff --git a/src/Logger.php b/src/Logger.php index db06e0c..ccf6e1f 100644 --- a/src/Logger.php +++ b/src/Logger.php @@ -11,19 +11,19 @@ * * Extends PSR-3 {@see LoggerInterface} to ensure compatibility with any PSR-3 consumer. * - * Implementations must support immutable context propagation: calling {@see withContext} + * Implementations must support immutable correlation propagation: calling {@see withCorrelation} * returns a new instance without mutating the original. */ interface Logger extends LoggerInterface { /** - * Creates a new Logger instance bound to the given correlation context. + * Creates a new Logger instance bound to the given correlation. * *

The original instance remains unchanged. An implementation returns its own type, so * declaring the concrete class name is the expected form.

* - * @param LogContext $context The log context containing the correlation ID. - * @return Logger A new Logger instance bound to the given context. + * @param Correlation $correlation The correlation containing the correlation ID. + * @return Logger A new Logger instance bound to the given correlation. */ - public function withContext(LogContext $context): Logger; + public function withCorrelation(Correlation $correlation): Logger; } diff --git a/src/Metrics/CloudWatch/EmbeddedMetricFormat.php b/src/Metrics/CloudWatch/EmbeddedMetricFormat.php new file mode 100644 index 0000000..05146eb --- /dev/null +++ b/src/Metrics/CloudWatch/EmbeddedMetricFormat.php @@ -0,0 +1,65 @@ +EMF publishes a metric by writing a log record that carries the metric inside it, which spares + * the publish API call. It never spares the custom metric tariff: every distinct pairing of metric + * name and dimension set is a series, and a series is billed. Which dimension names carry an + * unbounded number of values belongs to whoever emits, so this format refuses none until told which + * ones to refuse, and the refusal names the alternative.

+ * + *

The record has to reach the log stream as a JSON object on its own line, because that is what + * the agent parses, and that is the line a telemetry logger writes for every metric.

+ */ +final readonly class EmbeddedMetricFormat implements MetricFormat +{ + private function __construct(private UnboundedDimensions $unboundedDimensions) + { + } + + /** + * Creates the format refusing no dimension name. + * + * @return EmbeddedMetricFormat The created instance. + */ + public static function default(): EmbeddedMetricFormat + { + return new EmbeddedMetricFormat(unboundedDimensions: UnboundedDimensions::none()); + } + + public function format(Metric $metric): array + { + $dimension = $this->unboundedDimensions->firstIn(dimensions: $metric->dimensions); + + if (!is_null($dimension)) { + throw new UnboundedDimension(dimension: $dimension); + } + + return new CloudWatchEnvelope(metric: $metric)->toPayload(); + } + + /** + * Returns a copy of the format refusing the given dimension names. + * + *

Which names are unbounded is a property of the emitting domain and not of EMF, so the list + * is declared here rather than carried by the library.

+ * + * @param string ...$names The dimension names refused as unbounded. + * @return EmbeddedMetricFormat A copy refusing those names on top of the ones already refused. + */ + public function withUnboundedDimensions(string ...$names): EmbeddedMetricFormat + { + return new EmbeddedMetricFormat(unboundedDimensions: $this->unboundedDimensions->and(...$names)); + } +} diff --git a/src/Metrics/Metric.php b/src/Metrics/Metric.php new file mode 100644 index 0000000..056d0d9 --- /dev/null +++ b/src/Metrics/Metric.php @@ -0,0 +1,165 @@ +A metric carries a namespace, a name, a value in a unit, the dimensions it is broken down by, + * and the fields that travel with it. What separates the two last ones is cost, not shape: a + * dimension is what a backend indexes and bills a series for, while a field rides along in the same + * record and stays queryable without multiplying anything. A granular attribute belongs in a + * field.

+ * + *

Rendering is a {@see MetricFormat} concern. This type never knows which backend reads it.

+ */ +final readonly class Metric +{ + private function __construct( + public MetricName $name, + public MetricUnit $unit, + public int|float $value, + public array $fields, + public MetricNamespace $namespace, + public array $dimensions + ) { + $shared = array_key_first(array_intersect_key($fields, $dimensions)); + + if (!is_null($shared)) { + throw new DuplicateMetricIdentifier(identifier: (string)$shared); + } + } + + /** + * Creates a metric counting one occurrence, with no dimension and no field. + * + *

A measurement that is not a count derives from here through {@see Metric::withUnit()} and + * {@see Metric::withValue()}, so the only arguments a metric always takes are the two that + * identify it.

+ * + * @param string $name The metric name (e.g., OfferAccepted). + * @param string $namespace The namespace the series belongs to (e.g., Acme/Waitlist). + * @return Metric The created instance. + * @throws BlankMetricIdentifier If the name or the namespace carries nothing. + */ + public static function of(string $name, string $namespace): Metric + { + return new Metric( + name: MetricName::from(value: $name), + unit: MetricUnit::COUNT, + value: 1, + fields: [], + namespace: MetricNamespace::from(value: $namespace), + dimensions: [] + ); + } + + /** + * Returns a copy of the metric carrying no field at all. + * + *

What survives a redaction is what the record should carry, so a caller that filters the + * fields rebuilds them from an empty one instead of writing over the originals: a field the + * filter dropped has no value to write, and would otherwise stay.

+ * + * @return Metric A copy with every field removed. + */ + public function withoutFields(): Metric + { + return new Metric( + name: $this->name, + unit: $this->unit, + value: $this->value, + fields: [], + namespace: $this->namespace, + dimensions: $this->dimensions + ); + } + + /** + * Returns a copy of the metric expressed in another unit. + * + * @param MetricUnit $unit The unit the value is expressed in. + * @return Metric A copy with the unit set. + */ + public function withUnit(MetricUnit $unit): Metric + { + return new Metric( + name: $this->name, + unit: $unit, + value: $this->value, + fields: $this->fields, + namespace: $this->namespace, + dimensions: $this->dimensions + ); + } + + /** + * Returns a copy of the metric carrying one more field in the same record. + * + * @param string $name The field name. + * @param string|int|float $value The field value. + * @return Metric A copy with the field set. + * @throws BlankMetricIdentifier If the field name carries nothing. + * @throws DuplicateMetricIdentifier If a dimension already carries that name. + */ + public function withField(string $name, string|int|float $value): Metric + { + return new Metric( + name: $this->name, + unit: $this->unit, + value: $this->value, + fields: [...$this->fields, $name => MetricField::of(name: $name, value: $value)], + namespace: $this->namespace, + dimensions: $this->dimensions + ); + } + + /** + * Returns a copy of the metric with the measured value replaced. + * + * @param int|float $value The value the metric carries. + * @return Metric A copy with the value set. + */ + public function withValue(int|float $value): Metric + { + return new Metric( + name: $this->name, + unit: $this->unit, + value: $value, + fields: $this->fields, + namespace: $this->namespace, + dimensions: $this->dimensions + ); + } + + /** + * Returns a copy of the metric broken down by one more dimension. + * + *

Every distinct set of dimension values is a series of its own, so a dimension that takes an + * unbounded number of values multiplies what the backend stores and bills. A format refuses the + * names its consumer declares unbounded, and a granular attribute belongs in + * {@see Metric::withField()} instead.

+ * + * @param string $name The dimension name. + * @param string $value The dimension value. + * @return Metric A copy with the dimension set. + * @throws BlankMetricIdentifier If the dimension name carries nothing. + * @throws DuplicateMetricIdentifier If a field already carries that name. + */ + public function withDimension(string $name, string $value): Metric + { + return new Metric( + name: $this->name, + unit: $this->unit, + value: $this->value, + fields: $this->fields, + namespace: $this->namespace, + dimensions: [...$this->dimensions, $name => MetricDimension::of(name: $name, value: $value)] + ); + } +} diff --git a/src/Metrics/MetricDimension.php b/src/Metrics/MetricDimension.php new file mode 100644 index 0000000..1117617 --- /dev/null +++ b/src/Metrics/MetricDimension.php @@ -0,0 +1,38 @@ +Every distinct set of dimension values is a series of its own, which a backend indexes and + * bills for, so a dimension is the expensive half of a record. A name that carries nothing declares + * an axis the backend cannot resolve, and the type refuses one.

+ */ +final readonly class MetricDimension +{ + private function __construct(public string $name, public string $value) + { + } + + /** + * Creates a dimension from its name and its value. + * + * @param string $name The dimension name (e.g., plan). + * @param string $value The value it takes (e.g., saas). + * @return MetricDimension The created instance. + * @throws BlankMetricIdentifier If the name carries nothing. + */ + public static function of(string $name, string $value): MetricDimension + { + if (trim($name) === '') { + throw new BlankMetricIdentifier(identifier: 'dimension name'); + } + + return new MetricDimension(name: $name, value: $value); + } +} diff --git a/src/Metrics/MetricField.php b/src/Metrics/MetricField.php new file mode 100644 index 0000000..f3c6452 --- /dev/null +++ b/src/Metrics/MetricField.php @@ -0,0 +1,38 @@ +It is the cheap half: queryable where the record lands, and free of the cardinality that makes + * a dimension expensive, which is where a granular attribute belongs. A name that carries nothing + * leaves a value nobody can query for, and the type refuses one.

+ */ +final readonly class MetricField +{ + private function __construct(public string $name, public string|int|float $value) + { + } + + /** + * Creates a field from its name and its value. + * + * @param string $name The field name (e.g., tenant_id). + * @param string|int|float $value The value it carries. + * @return MetricField The created instance. + * @throws BlankMetricIdentifier If the name carries nothing. + */ + public static function of(string $name, string|int|float $value): MetricField + { + if (trim($name) === '') { + throw new BlankMetricIdentifier(identifier: 'field name'); + } + + return new MetricField(name: $name, value: $value); + } +} diff --git a/src/Metrics/MetricFormat.php b/src/Metrics/MetricFormat.php new file mode 100644 index 0000000..4207594 --- /dev/null +++ b/src/Metrics/MetricFormat.php @@ -0,0 +1,26 @@ +The seam that keeps the metric itself free of any backend. An implementation owns the shape + * of the record, the spelling of the units, and whichever dimension names it refuses, and lives in + * the folder of the backend it writes for.

+ */ +interface MetricFormat +{ + /** + * Renders the metric as the payload to be logged. + * + * @param Metric $metric The metric to render. + * @return array The payload the backend reads. + * @throws UnboundedDimension If the metric declares a dimension the format refuses. + */ + public function format(Metric $metric): array; +} diff --git a/src/Metrics/MetricName.php b/src/Metrics/MetricName.php new file mode 100644 index 0000000..8cf9a2a --- /dev/null +++ b/src/Metrics/MetricName.php @@ -0,0 +1,37 @@ +It names the series and, in a record that carries the metric inside it, it is also the key the + * value sits at. A name that carries nothing leaves a record whose value has no series to belong + * to, so the type refuses one rather than let the emptiness travel.

+ */ +final readonly class MetricName +{ + private function __construct(public string $value) + { + } + + /** + * Creates a name from its text. + * + * @param string $value The metric name (e.g., OfferAccepted). + * @return MetricName The created instance. + * @throws BlankMetricIdentifier If the text carries nothing. + */ + public static function from(string $value): MetricName + { + if (trim($value) === '') { + throw new BlankMetricIdentifier(identifier: 'name'); + } + + return new MetricName(value: $value); + } +} diff --git a/src/Metrics/MetricNamespace.php b/src/Metrics/MetricNamespace.php new file mode 100644 index 0000000..a3da37a --- /dev/null +++ b/src/Metrics/MetricNamespace.php @@ -0,0 +1,36 @@ +It is what separates one application's series from another's in the same backend, so a + * namespace that carries nothing puts the series where no one is looking. The type refuses one.

+ */ +final readonly class MetricNamespace +{ + private function __construct(public string $value) + { + } + + /** + * Creates a namespace from its text. + * + * @param string $value The namespace (e.g., Acme/Waitlist). + * @return MetricNamespace The created instance. + * @throws BlankMetricIdentifier If the text carries nothing. + */ + public static function from(string $value): MetricNamespace + { + if (trim($value) === '') { + throw new BlankMetricIdentifier(identifier: 'namespace'); + } + + return new MetricNamespace(value: $value); + } +} diff --git a/src/Metrics/MetricUnit.php b/src/Metrics/MetricUnit.php new file mode 100644 index 0000000..234a6a0 --- /dev/null +++ b/src/Metrics/MetricUnit.php @@ -0,0 +1,24 @@ +Base units only, which is what the neutral specifications prescribe: OpenTelemetry asks for + * non-prefixed units and for durations in seconds, and OpenMetrics asks for base units too. A + * duration of half a second is `0.5` in {@see MetricUnit::SECONDS}, never `500` in a millisecond + * unit that does not exist here. A backend that spells them its own way translates in its + * {@see MetricFormat}, and every backend can express these six.

+ */ +enum MetricUnit: string +{ + case BITS = 'bit'; + case NONE = '1'; + case BYTES = 'By'; + case COUNT = '{count}'; + case PERCENT = '%'; + case SECONDS = 's'; +} diff --git a/src/Redactions/BirthDateRedaction.php b/src/Redactions/BirthDateRedaction.php new file mode 100644 index 0000000..4f9a7e4 --- /dev/null +++ b/src/Redactions/BirthDateRedaction.php @@ -0,0 +1,68 @@ +A full date of birth identifies a person almost as well as a document does, while the year + * alone answers most of what a reader needs: whether the subject is a minor, which cohort a metric + * belongs to. The default keeps the four leading characters, which is the year of a date written as + * ISO 8601, and the separators survive so the shape still reads as a date. For a date written the + * other way around, name the window that keeps what you meant to keep.

+ * + *

The same value travels under more than one name, so the default covers birth*date + * and date_of_birth, however they are spelled.

+ */ +final readonly class BirthDateRedaction implements Redaction +{ + private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 4; + + private const array DEFAULT_FIELDS = ['birth*date', 'date_of_birth']; + + private Redaction $redactor; + + private function __construct(array $fields, int $visiblePrefixLength) + { + $this->redactor = GenericRedaction::masking( + mask: Mask::preservingSeparators(), + fields: $fields, + visibility: Visibility::edges(prefixLength: $visiblePrefixLength) + ); + } + + /** + * Creates a BirthDateRedaction from the fields to mask and the number of visible leading characters. + * + * @param string[] $fields The field names whose values are masked, wildcards accepted. + * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default. + * @return BirthDateRedaction The created instance. + * @throws NegativeVisibleLength If the visible prefix length is negative. + */ + public static function from(array $fields, ?int $visiblePrefixLength = null): BirthDateRedaction + { + return new BirthDateRedaction( + fields: $fields, + visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH) + ); + } + + /** + * Builds a BirthDateRedaction covering the names a date of birth travels under. + * + * @return BirthDateRedaction The created instance. + */ + public static function default(): BirthDateRedaction + { + return BirthDateRedaction::from(fields: self::DEFAULT_FIELDS); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redactions/DocumentRedaction.php b/src/Redactions/DocumentRedaction.php index 7d689c2..e009c5a 100644 --- a/src/Redactions/DocumentRedaction.php +++ b/src/Redactions/DocumentRedaction.php @@ -5,25 +5,22 @@ namespace TinyBlocks\Logger\Redactions; use TinyBlocks\Logger\Exceptions\NegativeVisibleLength; -use TinyBlocks\Logger\Mask; -use TinyBlocks\Logger\Redaction; -use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction; /** * Masks document field values, keeping a configurable number of trailing characters visible. */ final readonly class DocumentRedaction implements Redaction { - private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 3; + private const int DEFAULT_VISIBLE_SUFFIX_LENGTH = 2; private Redaction $redactor; private function __construct(array $fields, int $visibleSuffixLength) { - $this->redactor = VisibleEdgesRedaction::from( + $this->redactor = GenericRedaction::masking( mask: Mask::proportional(), fields: $fields, - visibleSuffixLength: $visibleSuffixLength + visibility: Visibility::edges(suffixLength: $visibleSuffixLength) ); } @@ -31,13 +28,16 @@ private function __construct(array $fields, int $visibleSuffixLength) * Creates a DocumentRedaction from the fields to mask and the number of visible trailing characters. * * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visibleSuffixLength The number of trailing characters left visible. + * @param int|null $visibleSuffixLength Trailing characters left visible, or null for the default. * @return DocumentRedaction The created instance. * @throws NegativeVisibleLength If the visible suffix length is negative. */ - public static function from(array $fields, int $visibleSuffixLength): DocumentRedaction + public static function from(array $fields, ?int $visibleSuffixLength = null): DocumentRedaction { - return new DocumentRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); + return new DocumentRedaction( + fields: $fields, + visibleSuffixLength: ($visibleSuffixLength ?? self::DEFAULT_VISIBLE_SUFFIX_LENGTH) + ); } /** @@ -47,7 +47,7 @@ public static function from(array $fields, int $visibleSuffixLength): DocumentRe */ public static function default(): DocumentRedaction { - return DocumentRedaction::from(fields: ['document'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + return DocumentRedaction::from(fields: ['document']); } public function redact(array $payload): array diff --git a/src/Redactions/EmailRedaction.php b/src/Redactions/EmailRedaction.php index e35700d..b919f15 100644 --- a/src/Redactions/EmailRedaction.php +++ b/src/Redactions/EmailRedaction.php @@ -5,12 +5,6 @@ namespace TinyBlocks\Logger\Redactions; use TinyBlocks\Logger\Exceptions\NegativeVisibleLength; -use TinyBlocks\Logger\Internal\Redactor\FieldMatcher; -use TinyBlocks\Logger\Internal\Redactor\Redactor; -use TinyBlocks\Logger\Internal\Redactor\VisibleEdges; -use TinyBlocks\Logger\Internal\Redactor\VisibleLocalPart; -use TinyBlocks\Logger\Mask; -use TinyBlocks\Logger\Redaction; /** * Masks the local part of email field values, keeping a configurable visible prefix and the domain. @@ -23,15 +17,10 @@ private function __construct(array $fields, int $visiblePrefixLength) { - $mask = Mask::proportional(); - $localPart = new VisibleLocalPart( - mask: $mask, - localPart: new VisibleEdges(mask: $mask, prefixLength: $visiblePrefixLength, suffixLength: 0) - ); - - $this->redactor = new Redactor( - fields: new FieldMatcher(fields: $fields), - maskingFunction: $localPart->applyTo(...) + $this->redactor = GenericRedaction::masking( + mask: Mask::proportional(), + fields: $fields, + visibility: Visibility::localPart(prefixLength: $visiblePrefixLength) ); } @@ -39,13 +28,16 @@ private function __construct(array $fields, int $visiblePrefixLength) * Creates an EmailRedaction from the fields to mask and the number of visible leading characters. * * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visiblePrefixLength The number of leading characters of the local part left visible. + * @param int|null $visiblePrefixLength Leading characters of the local part visible, or null for the default. * @return EmailRedaction The created instance. * @throws NegativeVisibleLength If the visible prefix length is negative. */ - public static function from(array $fields, int $visiblePrefixLength): EmailRedaction + public static function from(array $fields, ?int $visiblePrefixLength = null): EmailRedaction { - return new EmailRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength); + return new EmailRedaction( + fields: $fields, + visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH) + ); } /** @@ -55,7 +47,7 @@ public static function from(array $fields, int $visiblePrefixLength): EmailRedac */ public static function default(): EmailRedaction { - return EmailRedaction::from(fields: ['email'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); + return EmailRedaction::from(fields: ['email']); } public function redact(array $payload): array diff --git a/src/Redactions/FilterExpressionRedaction.php b/src/Redactions/FilterExpressionRedaction.php new file mode 100644 index 0000000..e367f4f --- /dev/null +++ b/src/Redactions/FilterExpressionRedaction.php @@ -0,0 +1,43 @@ +A filter arrives as one string, so the field names inside it are out of reach of any field + * based strategy, and the operand is where the sensitive value sits. Written for the comparison + * syntax RSQL and FIQL share, where status==active and document=in=(...) + * are the shapes a query takes, including the parenthesized list of an =in= comparison. + * What is asked stays readable, what is asked about does not.

+ */ +final readonly class FilterExpressionRedaction implements Redaction +{ + private const string PATTERN = '/([^;,()=!<>\s]+(?:==|!=|=[a-z]{2,4}=))(\([^)]*\)|[^;,()\s]*)/i'; + + private const string REPLACEMENT = '${1}********'; + + private Redaction $redactor; + + private function __construct() + { + $this->redactor = GenericRedaction::replacing(pattern: self::PATTERN, replacement: self::REPLACEMENT); + } + + /** + * Builds a FilterExpressionRedaction masking the operand of every comparison it finds. + * + * @return FilterExpressionRedaction The created instance. + */ + public static function default(): FilterExpressionRedaction + { + return new FilterExpressionRedaction(); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redactions/GenericRedaction.php b/src/Redactions/GenericRedaction.php new file mode 100644 index 0000000..b4bfc21 --- /dev/null +++ b/src/Redactions/GenericRedaction.php @@ -0,0 +1,119 @@ +The strategies named after a kind of data are this one with the decisions already made: which + * fields are covered, which mask renders them, and how much of the value survives. Reach for this + * one whenever none of them fits, and for whatever they cannot express: dropping a field, keeping + * an allow list, rewriting matched text, or narrowing another redaction to one branch.

+ */ +final readonly class GenericRedaction implements Redaction +{ + private function __construct(private Redaction $redactor) + { + } + + /** + * Creates a GenericRedaction applying another redaction only under the given parent field. + * + * @param string $parent The parent field name whose sub payloads the redaction reaches. + * @param Redaction $redaction The redaction applied within that branch. + * @return GenericRedaction The created instance. + */ + public static function under(string $parent, Redaction $redaction): GenericRedaction + { + return new GenericRedaction( + redactor: new ScopedRedactor(scope: new FieldMatcher(fields: [$parent]), redaction: $redaction) + ); + } + + /** + * Creates a GenericRedaction masking every value whose field is not on the allow list. + * + *

The inverse of naming what to hide. Naming what to keep removes the leak by omission: a + * field added later is masked until it is explicitly allowed.

+ * + * @param string[] $fields The field names left untouched, wildcards accepted. + * @param Mask|null $mask The strategy rendering every value outside the allow list, or null for a fixed mask. + * @return GenericRedaction The created instance. + */ + public static function keeping(array $fields, ?Mask $mask = null): GenericRedaction + { + $rendering = ($mask ?? Mask::fixed()); + + return new GenericRedaction( + redactor: new VisibleFieldRedactor( + fields: new FieldMatcher(fields: $fields), + maskingFunction: $rendering->applyTo(...) + ) + ); + } + + /** + * Creates a GenericRedaction masking the given fields, at any depth. + * + * @param Mask $mask The strategy rendering the hidden portion of each value. + * @param string[] $fields The field names covered, wildcards accepted. + * @param Visibility|null $visibility How much of each value survives, or null to hide it whole. + * @return GenericRedaction The created instance. + */ + public static function masking(Mask $mask, array $fields, ?Visibility $visibility = null): GenericRedaction + { + $visible = ($visibility ?? Visibility::none()); + + return new GenericRedaction( + redactor: new Redactor( + fields: new FieldMatcher(fields: $fields), + maskingFunction: static fn(string $value): string => $visible->applyTo(mask: $mask, value: $value) + ) + ); + } + + /** + * Creates a GenericRedaction dropping the given fields, at any depth. + * + *

The key itself is gone from the output, which is what a value with no meaning once masked + * deserves: a stack trace, a payment code, a raw user agent.

+ * + * @param string[] $fields The field names removed, wildcards accepted. + * @return GenericRedaction The created instance. + */ + public static function removing(array $fields): GenericRedaction + { + return new GenericRedaction(redactor: new FieldRemover(fields: new FieldMatcher(fields: $fields))); + } + + /** + * Creates a GenericRedaction rewriting every match of the pattern, in every value. + * + *

Field names are not consulted, which is what reaches sensitive data embedded in free text: + * an exception message quoting a document, a URL carrying a token.

+ * + * @param string $pattern The regular expression matched against every string value. + * @param string $replacement The replacement, which may reference capture groups. + * @return GenericRedaction The created instance. + * @throws MalformedRedactionPattern If the pattern is not a valid regular expression. + */ + public static function replacing(string $pattern, string $replacement): GenericRedaction + { + return new GenericRedaction(redactor: new PatternRedactor(pattern: $pattern, replacement: $replacement)); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Mask.php b/src/Redactions/Mask.php similarity index 89% rename from src/Mask.php rename to src/Redactions/Mask.php index 6d05976..c57ca37 100644 --- a/src/Mask.php +++ b/src/Redactions/Mask.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Logger; +namespace TinyBlocks\Logger\Redactions; use TinyBlocks\Logger\Internal\Redactor\MaskStyle; @@ -15,6 +15,8 @@ */ final readonly class Mask { + private const int DEFAULT_LENGTH = 8; + private function __construct(private MaskStyle $style, private string $fixedMask = '') { } @@ -25,10 +27,10 @@ private function __construct(private MaskStyle $style, private string $fixedMask *

The length of the original value is never revealed, and the mask is emitted even when * nothing was hidden, so a short value cannot be told apart from a long one.

* - * @param int $length The number of mask characters emitted. + * @param int $length The number of mask characters emitted, eight when not given. * @return Mask The created instance. */ - public static function fixed(int $length): Mask + public static function fixed(int $length = self::DEFAULT_LENGTH): Mask { return new Mask(style: MaskStyle::FIXED, fixedMask: MaskStyle::fixedMaskOf(length: $length)); } diff --git a/src/Redactions/NameRedaction.php b/src/Redactions/NameRedaction.php index 21d9c99..015c296 100644 --- a/src/Redactions/NameRedaction.php +++ b/src/Redactions/NameRedaction.php @@ -5,9 +5,6 @@ namespace TinyBlocks\Logger\Redactions; use TinyBlocks\Logger\Exceptions\NegativeVisibleLength; -use TinyBlocks\Logger\Mask; -use TinyBlocks\Logger\Redaction; -use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction; /** * Masks name field values, keeping a configurable number of leading characters visible. @@ -20,10 +17,10 @@ private function __construct(array $fields, int $visiblePrefixLength) { - $this->redactor = VisibleEdgesRedaction::from( + $this->redactor = GenericRedaction::masking( mask: Mask::proportional(), fields: $fields, - visiblePrefixLength: $visiblePrefixLength + visibility: Visibility::edges(prefixLength: $visiblePrefixLength) ); } @@ -31,13 +28,16 @@ private function __construct(array $fields, int $visiblePrefixLength) * Creates a NameRedaction from the fields to mask and the number of visible leading characters. * * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visiblePrefixLength The number of leading characters left visible. + * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default. * @return NameRedaction The created instance. * @throws NegativeVisibleLength If the visible prefix length is negative. */ - public static function from(array $fields, int $visiblePrefixLength): NameRedaction + public static function from(array $fields, ?int $visiblePrefixLength = null): NameRedaction { - return new NameRedaction(fields: $fields, visiblePrefixLength: $visiblePrefixLength); + return new NameRedaction( + fields: $fields, + visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH) + ); } /** @@ -47,7 +47,7 @@ public static function from(array $fields, int $visiblePrefixLength): NameRedact */ public static function default(): NameRedaction { - return NameRedaction::from(fields: ['name'], visiblePrefixLength: self::DEFAULT_VISIBLE_PREFIX_LENGTH); + return NameRedaction::from(fields: ['name']); } public function redact(array $payload): array diff --git a/src/Redactions/PasswordRedaction.php b/src/Redactions/PasswordRedaction.php deleted file mode 100644 index ab49e0f..0000000 --- a/src/Redactions/PasswordRedaction.php +++ /dev/null @@ -1,53 +0,0 @@ -redactor = FullMaskRedaction::from(mask: Mask::fixed(length: $fixedMaskLength), fields: $fields); - } - - /** - * Creates a PasswordRedaction from the fields to mask and the fixed mask length. - * - * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $fixedMaskLength The fixed number of mask characters emitted. - * @return PasswordRedaction The created instance. - */ - public static function from( - array $fields, - int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH - ): PasswordRedaction { - return new PasswordRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength); - } - - /** - * Builds a PasswordRedaction with the default password field and fixed mask length. - * - * @return PasswordRedaction The created instance. - */ - public static function default(): PasswordRedaction - { - return PasswordRedaction::from(fields: ['password']); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/PhoneRedaction.php b/src/Redactions/PhoneRedaction.php index 3c2324a..4546875 100644 --- a/src/Redactions/PhoneRedaction.php +++ b/src/Redactions/PhoneRedaction.php @@ -5,9 +5,6 @@ namespace TinyBlocks\Logger\Redactions; use TinyBlocks\Logger\Exceptions\NegativeVisibleLength; -use TinyBlocks\Logger\Mask; -use TinyBlocks\Logger\Redaction; -use TinyBlocks\Logger\Redactions\Rules\VisibleEdgesRedaction; /** * Masks phone field values, keeping a configurable number of trailing characters visible. @@ -20,10 +17,10 @@ private function __construct(array $fields, int $visibleSuffixLength) { - $this->redactor = VisibleEdgesRedaction::from( + $this->redactor = GenericRedaction::masking( mask: Mask::proportional(), fields: $fields, - visibleSuffixLength: $visibleSuffixLength + visibility: Visibility::edges(suffixLength: $visibleSuffixLength) ); } @@ -31,13 +28,16 @@ private function __construct(array $fields, int $visibleSuffixLength) * Creates a PhoneRedaction from the fields to mask and the number of visible trailing characters. * * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visibleSuffixLength The number of trailing characters left visible. + * @param int|null $visibleSuffixLength Trailing characters left visible, or null for the default. * @return PhoneRedaction The created instance. * @throws NegativeVisibleLength If the visible suffix length is negative. */ - public static function from(array $fields, int $visibleSuffixLength): PhoneRedaction + public static function from(array $fields, ?int $visibleSuffixLength = null): PhoneRedaction { - return new PhoneRedaction(fields: $fields, visibleSuffixLength: $visibleSuffixLength); + return new PhoneRedaction( + fields: $fields, + visibleSuffixLength: ($visibleSuffixLength ?? self::DEFAULT_VISIBLE_SUFFIX_LENGTH) + ); } /** @@ -47,7 +47,7 @@ public static function from(array $fields, int $visibleSuffixLength): PhoneRedac */ public static function default(): PhoneRedaction { - return PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: self::DEFAULT_VISIBLE_SUFFIX_LENGTH); + return PhoneRedaction::from(fields: ['phone']); } public function redact(array $payload): array diff --git a/src/Redactions/PostalCodeRedaction.php b/src/Redactions/PostalCodeRedaction.php new file mode 100644 index 0000000..3072795 --- /dev/null +++ b/src/Redactions/PostalCodeRedaction.php @@ -0,0 +1,67 @@ +A full postal code narrows to a street, and sometimes to a building. The leading characters + * name a region and are what a reader needs to tell one market from another, so the default keeps + * three of them and hides the rest, with the separators preserved.

+ * + *

The same value travels under more than one name, so the default covers the postal code and the + * zip code alike, however they are spelled: post*code and zip*code. A bare + * zip is left out, since a field by that name is as likely to carry an archive.

+ */ +final readonly class PostalCodeRedaction implements Redaction +{ + private const int DEFAULT_VISIBLE_PREFIX_LENGTH = 3; + + private const array DEFAULT_FIELDS = ['post*code', 'zip*code']; + + private Redaction $redactor; + + private function __construct(array $fields, int $visiblePrefixLength) + { + $this->redactor = GenericRedaction::masking( + mask: Mask::preservingSeparators(), + fields: $fields, + visibility: Visibility::edges(prefixLength: $visiblePrefixLength) + ); + } + + /** + * Creates a PostalCodeRedaction from the fields to mask and the number of visible leading characters. + * + * @param string[] $fields The field names whose values are masked, wildcards accepted. + * @param int|null $visiblePrefixLength Leading characters left visible, or null for the default. + * @return PostalCodeRedaction The created instance. + * @throws NegativeVisibleLength If the visible prefix length is negative. + */ + public static function from(array $fields, ?int $visiblePrefixLength = null): PostalCodeRedaction + { + return new PostalCodeRedaction( + fields: $fields, + visiblePrefixLength: ($visiblePrefixLength ?? self::DEFAULT_VISIBLE_PREFIX_LENGTH) + ); + } + + /** + * Builds a PostalCodeRedaction covering the names a postal code travels under. + * + * @return PostalCodeRedaction The created instance. + */ + public static function default(): PostalCodeRedaction + { + return PostalCodeRedaction::from(fields: self::DEFAULT_FIELDS); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redactions/QueryParametersRedaction.php b/src/Redactions/QueryParametersRedaction.php new file mode 100644 index 0000000..640301f --- /dev/null +++ b/src/Redactions/QueryParametersRedaction.php @@ -0,0 +1,57 @@ +A query parameter is a value someone put in a URL, so it carries whatever the caller sent: an + * identifier, a document, a token. Naming what stays readable removes the leak by omission, since a + * parameter added later is masked until it is allowed.

+ * + *

It reaches the query_parameters branch, which is where the request log middleware + * of this ecosystem puts them. For a payload that carries them under another name, compose + * {@see GenericRedaction::under()} with {@see GenericRedaction::keeping()} instead.

+ */ +final readonly class QueryParametersRedaction implements Redaction +{ + private const string PARENT = 'query_parameters'; + + private Redaction $redactor; + + private function __construct(array $fields) + { + $this->redactor = GenericRedaction::under( + parent: self::PARENT, + redaction: GenericRedaction::keeping(fields: $fields) + ); + } + + /** + * Creates a QueryParametersRedaction keeping only the named parameters readable. + * + * @param string[] $fields The parameter names left untouched, wildcards accepted. + * @return QueryParametersRedaction The created instance. + */ + public static function keeping(array $fields): QueryParametersRedaction + { + return new QueryParametersRedaction(fields: $fields); + } + + /** + * Builds a QueryParametersRedaction masking every query parameter. + * + * @return QueryParametersRedaction The created instance. + */ + public static function default(): QueryParametersRedaction + { + return QueryParametersRedaction::keeping(fields: []); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redactions/QueryStringRedaction.php b/src/Redactions/QueryStringRedaction.php new file mode 100644 index 0000000..01dc8ac --- /dev/null +++ b/src/Redactions/QueryStringRedaction.php @@ -0,0 +1,41 @@ +A URL reaches the log as an ordinary string, so no field name covers what rides after the + * question mark: an identifier, a token, a filter carrying a document. The path is what a reader + * needs to know which route was called, and it survives.

+ */ +final readonly class QueryStringRedaction implements Redaction +{ + private const string PATTERN = '/(\?)[^\s]*/'; + + private const string REPLACEMENT = '${1}'; + + private Redaction $redactor; + + private function __construct() + { + $this->redactor = GenericRedaction::replacing(pattern: self::PATTERN, replacement: self::REPLACEMENT); + } + + /** + * Builds a QueryStringRedaction dropping everything after the question mark of every URL. + * + * @return QueryStringRedaction The created instance. + */ + public static function default(): QueryStringRedaction + { + return new QueryStringRedaction(); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redaction.php b/src/Redactions/Redaction.php similarity index 93% rename from src/Redaction.php rename to src/Redactions/Redaction.php index e74253a..a5adde6 100644 --- a/src/Redaction.php +++ b/src/Redactions/Redaction.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace TinyBlocks\Logger; +namespace TinyBlocks\Logger\Redactions; /** * Defines the contract for redacting sensitive information from structured log data. diff --git a/src/Redactions/Rules/AllowedFieldsRedaction.php b/src/Redactions/Rules/AllowedFieldsRedaction.php deleted file mode 100644 index 5272284..0000000 --- a/src/Redactions/Rules/AllowedFieldsRedaction.php +++ /dev/null @@ -1,48 +0,0 @@ -The inverse of the other strategies, which name what to hide. Naming what to keep removes the - * leak by omission: a field added later is masked until it is explicitly allowed. Pair it with - * {@see ScopedRedaction} to apply the allow list to one branch of the payload instead of all of - * it.

- */ -final readonly class AllowedFieldsRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(Mask $mask, array $fields) - { - $this->redactor = new RetainedFieldRedactor( - fields: new FieldMatcher(fields: $fields), - maskingFunction: $mask->applyTo(...) - ); - } - - /** - * Creates an AllowedFieldsRedaction from the mask and the fields left untouched. - * - * @param Mask $mask The strategy rendering every value outside the allow list. - * @param string[] $fields The field names left untouched, wildcards accepted. - * @return AllowedFieldsRedaction The created instance. - */ - public static function from(Mask $mask, array $fields): AllowedFieldsRedaction - { - return new AllowedFieldsRedaction(mask: $mask, fields: $fields); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/FullMaskRedaction.php b/src/Redactions/Rules/FullMaskRedaction.php deleted file mode 100644 index f2e1cb8..0000000 --- a/src/Redactions/Rules/FullMaskRedaction.php +++ /dev/null @@ -1,76 +0,0 @@ -The strategy for values that carry no operational meaning once logged, such as secrets, free - * text, network addresses, and user agents.

- */ -final readonly class FullMaskRedaction implements Redaction -{ - private const int SECRET_MASK_LENGTH = 8; - - private const array COMMON_SECRET_FIELDS = [ - '*token*', - '*secret*', - '*api_key*', - '*password*', - 'credentials', - 'authorization', - '*private_key*' - ]; - - private Redaction $redactor; - - private function __construct(Mask $mask, array $fields) - { - $this->redactor = new Redactor( - fields: new FieldMatcher(fields: $fields), - maskingFunction: $mask->applyTo(...) - ); - } - - /** - * Creates a FullMaskRedaction from the mask and the fields to redact. - * - * @param Mask $mask The strategy rendering the masked value. - * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @return FullMaskRedaction The created instance. - */ - public static function from(Mask $mask, array $fields): FullMaskRedaction - { - return new FullMaskRedaction(mask: $mask, fields: $fields); - } - - /** - * Builds a FullMaskRedaction covering the field names that carry secrets across most systems. - * - *

Matches every field whose name contains token, secret, - * api_key, password, or private_key, plus - * credentials and authorization. The mask has a fixed width, so the - * length of the secret is never revealed.

- * - * @return FullMaskRedaction The created instance. - */ - public static function commonSecrets(): FullMaskRedaction - { - return FullMaskRedaction::from( - mask: Mask::fixed(length: self::SECRET_MASK_LENGTH), - fields: self::COMMON_SECRET_FIELDS - ); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/PatternRedaction.php b/src/Redactions/Rules/PatternRedaction.php deleted file mode 100644 index af7da06..0000000 --- a/src/Redactions/Rules/PatternRedaction.php +++ /dev/null @@ -1,44 +0,0 @@ -Field-based strategies cannot reach sensitive data embedded in free text: an exception message - * quoting a document, a stack trace, a URI carrying a query string. Matching on the value instead of - * the key closes that gap.

- */ -final readonly class PatternRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(string $pattern, string $replacement) - { - $this->redactor = new PatternRedactor(pattern: $pattern, replacement: $replacement); - } - - /** - * Creates a PatternRedaction from the pattern to match and the text replacing every match. - * - * @param string $pattern The regular expression matched against every string value. - * @param string $replacement The text every match is replaced with. - * @return PatternRedaction The created instance. - * @throws InvalidRedactionPattern If the regular expression engine rejects the pattern. - */ - public static function from(string $pattern, string $replacement): PatternRedaction - { - return new PatternRedaction(pattern: $pattern, replacement: $replacement); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/RemovedFieldsRedaction.php b/src/Redactions/Rules/RemovedFieldsRedaction.php deleted file mode 100644 index 404781e..0000000 --- a/src/Redactions/Rules/RemovedFieldsRedaction.php +++ /dev/null @@ -1,41 +0,0 @@ -Preferred over a mask when the field carries no diagnostic value at all, such as a stack trace - * or a raw payment code. Nothing about the original value reaches the log, not even its presence.

- */ -final readonly class RemovedFieldsRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(array $fields) - { - $this->redactor = new FieldRemover(fields: new FieldMatcher(fields: $fields)); - } - - /** - * Creates a RemovedFieldsRedaction from the fields to drop. - * - * @param string[] $fields The field names removed from the payload, wildcards accepted. - * @return RemovedFieldsRedaction The created instance. - */ - public static function from(array $fields): RemovedFieldsRedaction - { - return new RemovedFieldsRedaction(fields: $fields); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/ScopedRedaction.php b/src/Redactions/Rules/ScopedRedaction.php deleted file mode 100644 index bc98b11..0000000 --- a/src/Redactions/Rules/ScopedRedaction.php +++ /dev/null @@ -1,48 +0,0 @@ -Field names repeat across a payload with different meanings. A value under - * document is an identity document, a value under metadata - * is an operational marker. Scoping a redaction to its parent keeps the first masked and the second - * readable.

- */ -final readonly class ScopedRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(string $parent, Redaction $redaction) - { - $this->redactor = new ScopedRedactor( - scope: new FieldMatcher(fields: [$parent]), - redaction: $redaction - ); - } - - /** - * Creates a ScopedRedaction applying the given redaction only under the given parent field. - * - * @param string $parent The field name whose sub payloads the redaction is restricted to, - * wildcards accepted. - * @param Redaction $redaction The redaction applied within that scope. - * @return ScopedRedaction The created instance. - */ - public static function under(string $parent, Redaction $redaction): ScopedRedaction - { - return new ScopedRedaction(parent: $parent, redaction: $redaction); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/VisibleEdgesRedaction.php b/src/Redactions/Rules/VisibleEdgesRedaction.php deleted file mode 100644 index 804f521..0000000 --- a/src/Redactions/Rules/VisibleEdgesRedaction.php +++ /dev/null @@ -1,66 +0,0 @@ -The general primitive behind the field-specific strategies. Use it whenever a value must keep - * part of its head, part of its tail, or both, and the domain strategies do not fit.

- */ -final readonly class VisibleEdgesRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength) - { - $edges = new VisibleEdges( - mask: $mask, - prefixLength: $visiblePrefixLength, - suffixLength: $visibleSuffixLength - ); - - $this->redactor = new Redactor( - fields: new FieldMatcher(fields: $fields), - maskingFunction: $edges->applyTo(...) - ); - } - - /** - * Creates a VisibleEdgesRedaction from the mask, the fields to redact, and the visible edges. - * - * @param Mask $mask The strategy rendering the hidden portion of each value. - * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visiblePrefixLength The number of leading characters left visible. - * @param int $visibleSuffixLength The number of trailing characters left visible. - * @return VisibleEdgesRedaction The created instance. - * @throws NegativeVisibleLength If either visible length is negative. - */ - public static function from( - Mask $mask, - array $fields, - int $visiblePrefixLength = 0, - int $visibleSuffixLength = 0 - ): VisibleEdgesRedaction { - return new VisibleEdgesRedaction( - mask: $mask, - fields: $fields, - visiblePrefixLength: $visiblePrefixLength, - visibleSuffixLength: $visibleSuffixLength - ); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/Rules/WordwiseRedaction.php b/src/Redactions/Rules/WordwiseRedaction.php deleted file mode 100644 index 5d76c13..0000000 --- a/src/Redactions/Rules/WordwiseRedaction.php +++ /dev/null @@ -1,70 +0,0 @@ -Suited to values made of several parts, such as a full name, where masking the value as a - * single run collapses it into an unreadable line. Words are separated by whitespace and rejoined - * with a single space.

- */ -final readonly class WordwiseRedaction implements Redaction -{ - private Redaction $redactor; - - private function __construct(Mask $mask, array $fields, int $visiblePrefixLength, int $visibleSuffixLength) - { - $words = new VisibleWords( - edges: new VisibleEdges( - mask: $mask, - prefixLength: $visiblePrefixLength, - suffixLength: $visibleSuffixLength - ) - ); - - $this->redactor = new Redactor( - fields: new FieldMatcher(fields: $fields), - maskingFunction: $words->applyTo(...) - ); - } - - /** - * Creates a WordwiseRedaction from the mask, the fields to redact, and the visible edges. - * - * @param Mask $mask The strategy rendering the hidden portion of each word. - * @param string[] $fields The field names whose values are masked, wildcards accepted. - * @param int $visiblePrefixLength The number of leading characters of each word left visible. - * @param int $visibleSuffixLength The number of trailing characters of each word left visible. - * @return WordwiseRedaction The created instance. - * @throws NegativeVisibleLength If either visible length is negative. - */ - public static function from( - Mask $mask, - array $fields, - int $visiblePrefixLength = 0, - int $visibleSuffixLength = 0 - ): WordwiseRedaction { - return new WordwiseRedaction( - mask: $mask, - fields: $fields, - visiblePrefixLength: $visiblePrefixLength, - visibleSuffixLength: $visibleSuffixLength - ); - } - - public function redact(array $payload): array - { - return $this->redactor->redact(payload: $payload); - } -} diff --git a/src/Redactions/SecretRedaction.php b/src/Redactions/SecretRedaction.php new file mode 100644 index 0000000..333084e --- /dev/null +++ b/src/Redactions/SecretRedaction.php @@ -0,0 +1,61 @@ +A secret carries no operational meaning once logged, and its length is itself a clue, so the + * mask is fixed. The default field list follows the naming conventions credentials travel under + * rather than a fixed set of names, which is what covers the field nobody remembered to declare.

+ */ +final readonly class SecretRedaction implements Redaction +{ + private const int DEFAULT_FIXED_MASK_LENGTH = 8; + + private const array DEFAULT_FIELDS = [ + '*token*', + '*secret*', + '*api_key*', + '*password*', + 'credentials', + 'authorization', + '*private_key*' + ]; + + private Redaction $redactor; + + private function __construct(array $fields, int $fixedMaskLength) + { + $this->redactor = GenericRedaction::masking(mask: Mask::fixed(length: $fixedMaskLength), fields: $fields); + } + + /** + * Creates a SecretRedaction from the fields to mask and the length of the fixed mask. + * + * @param string[] $fields The field names whose values are masked, wildcards accepted. + * @param int $fixedMaskLength The number of mask characters emitted for every value. + * @return SecretRedaction The created instance. + */ + public static function from(array $fields, int $fixedMaskLength = self::DEFAULT_FIXED_MASK_LENGTH): SecretRedaction + { + return new SecretRedaction(fields: $fields, fixedMaskLength: $fixedMaskLength); + } + + /** + * Builds a SecretRedaction covering the field name patterns credentials commonly travel under. + * + * @return SecretRedaction The created instance. + */ + public static function default(): SecretRedaction + { + return SecretRedaction::from(fields: self::DEFAULT_FIELDS); + } + + public function redact(array $payload): array + { + return $this->redactor->redact(payload: $payload); + } +} diff --git a/src/Redactions/Visibility.php b/src/Redactions/Visibility.php new file mode 100644 index 0000000..f25b8a8 --- /dev/null +++ b/src/Redactions/Visibility.php @@ -0,0 +1,109 @@ +The sibling decision of {@see Mask}, which renders the hidden portion. This one decides which + * portion is hidden at all: nothing survives, the edges of the value survive, the edges of every + * word survive, or everything before the at sign of an address is treated as the value.

+ */ +final readonly class Visibility +{ + private function __construct( + private VisibleShape $shape, + private int $prefixLength, + private int $suffixLength + ) { + if (min($prefixLength, $suffixLength) < 0) { + $template = 'Visible length cannot be negative, got prefix %d and suffix %d.'; + + throw new NegativeVisibleLength(message: sprintf($template, $prefixLength, $suffixLength)); + } + } + + /** + * Creates a Visibility where nothing of the value survives. + * + * @return Visibility The created instance. + */ + public static function none(): Visibility + { + return new Visibility(shape: VisibleShape::EDGES, prefixLength: 0, suffixLength: 0); + } + + /** + * Creates a Visibility keeping a window at the head, at the tail, or at both ends of the value. + * + * @param int $prefixLength The number of leading characters left visible. + * @param int $suffixLength The number of trailing characters left visible. + * @return Visibility The created instance. + * @throws NegativeVisibleLength If either length is negative. + */ + public static function edges(int $prefixLength = 0, int $suffixLength = 0): Visibility + { + return new Visibility( + shape: VisibleShape::EDGES, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ); + } + + /** + * Creates a Visibility keeping the same window on every word of the value. + * + *

The value is split on whitespace and each word is masked on its own, so a multi word label + * keeps its shape instead of collapsing into a single run of mask characters.

+ * + * @param int $prefixLength The number of leading characters left visible in each word. + * @param int $suffixLength The number of trailing characters left visible in each word. + * @return Visibility The created instance. + * @throws NegativeVisibleLength If either length is negative. + */ + public static function words(int $prefixLength = 0, int $suffixLength = 0): Visibility + { + return new Visibility( + shape: VisibleShape::WORDS, + prefixLength: $prefixLength, + suffixLength: $suffixLength + ); + } + + /** + * Creates a Visibility masking only what comes before the at sign, keeping the domain intact. + * + *

A value with no at sign is hidden whole, so a malformed address never leaks by falling + * outside the rule.

+ * + * @param int $prefixLength The number of leading characters of the local part left visible. + * @return Visibility The created instance. + * @throws NegativeVisibleLength If the length is negative. + */ + public static function localPart(int $prefixLength): Visibility + { + return new Visibility(shape: VisibleShape::LOCAL_PART, prefixLength: $prefixLength, suffixLength: 0); + } + + /** + * Applies the mask to the portion of the value this visibility hides. + * + * @param Mask $mask The strategy rendering the hidden portion. + * @param string $value The original value. + * @return string The value with its hidden portion masked. + */ + public function applyTo(Mask $mask, string $value): string + { + return $this->shape->applyTo( + mask: $mask, + value: $value, + prefixLength: $this->prefixLength, + suffixLength: $this->suffixLength + ); + } +} diff --git a/src/StreamLogger.php b/src/StreamLogger.php new file mode 100644 index 0000000..b355891 --- /dev/null +++ b/src/StreamLogger.php @@ -0,0 +1,97 @@ +template === '' + ? LogFormatter::fromComponent(component: $builder->component) + : LogFormatter::fromTemplate(template: $builder->template, component: $builder->component); + + return new StreamLogger( + stream: LogStream::from(resource: $builder->stream), + formatter: $formatter, + redactions: Redactions::createFrom(elements: $builder->redactions), + correlation: $builder->correlation, + minimumLevel: $builder->minimumLevel + ); + } + + /** + * Creates a StreamLoggerBuilder. + * + * @return StreamLoggerBuilder A new builder for configuring a StreamLogger. + */ + public static function builder(): StreamLoggerBuilder + { + return StreamLoggerBuilder::default(); + } + + /** + * Writes a log entry to the stream when its severity reaches the configured minimum level. + * + * @param mixed $level The severity, as received by the PSR-3 contract. + * @param string|Stringable $message The message key identifying the event. + * @param array $context The context data carried by the entry. + * @throws UnknownLogLevel If the level is outside the supported PSR-3 set. + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $logLevel = LogLevel::fromPsrLevel(level: $level); + + if (!$logLevel->isAtLeast(threshold: $this->minimumLevel)) { + return; + } + + $formatted = $this->formatter->format( + key: (string)$message, + level: $logLevel, + payload: $this->redactions->applyTo(payload: $context), + correlation: $this->correlation + ); + + $this->stream->write(content: $formatted); + } + + public function withCorrelation(Correlation $correlation): StreamLogger + { + return new StreamLogger( + stream: $this->stream, + formatter: $this->formatter, + redactions: $this->redactions, + correlation: $correlation, + minimumLevel: $this->minimumLevel + ); + } +} diff --git a/src/StreamLoggerBuilder.php b/src/StreamLoggerBuilder.php new file mode 100644 index 0000000..52d4b6c --- /dev/null +++ b/src/StreamLoggerBuilder.php @@ -0,0 +1,162 @@ +Immutable, so every with returns a copy and the builder it came from still + * describes what it described before.

+ */ +final readonly class StreamLoggerBuilder +{ + private function __construct( + public mixed $stream, + public string $template, + public string $component, + public array $redactions, + public ?Correlation $correlation, + public LogLevel $minimumLevel + ) { + } + + /** + * Creates a StreamLoggerBuilder with the baseline description. + * + * @return StreamLoggerBuilder A builder writing to standard error, with no redaction and no threshold. + */ + public static function default(): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: null, + template: '', + component: '', + redactions: [], + correlation: null, + minimumLevel: LogLevel::DEBUG + ); + } + + /** + * Builds a StreamLogger from what the builder describes. + * + * @return StreamLogger The configured logger instance. + */ + public function build(): StreamLogger + { + return StreamLogger::from(builder: $this); + } + + /** + * Returns a copy of the builder with the stream replaced. + * + * @param mixed $stream The stream log entries are written to. + * @return StreamLoggerBuilder A copy of the builder with the stream set. + */ + public function withStream(mixed $stream): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $stream, + template: $this->template, + component: $this->component, + redactions: $this->redactions, + correlation: $this->correlation, + minimumLevel: $this->minimumLevel + ); + } + + /** + * Returns a copy of the builder with the format template replaced. + * + * @param string $template The format template applied to every entry. + * @return StreamLoggerBuilder A copy of the builder with the template set. + */ + public function withTemplate(string $template): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $this->stream, + template: $template, + component: $this->component, + redactions: $this->redactions, + correlation: $this->correlation, + minimumLevel: $this->minimumLevel + ); + } + + /** + * Returns a copy of the builder with the component replaced. + * + * @param string $component The component name identifying the log source. + * @return StreamLoggerBuilder A copy of the builder with the component set. + */ + public function withComponent(string $component): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $this->stream, + template: $this->template, + component: $component, + redactions: $this->redactions, + correlation: $this->correlation, + minimumLevel: $this->minimumLevel + ); + } + + /** + * Returns a copy of the builder with the redactions added to the ones already described. + * + * @param Redaction ...$redactions The redaction strategies applied before writing. + * @return StreamLoggerBuilder A copy of the builder carrying the previous and the new redactions. + */ + public function withRedactions(Redaction ...$redactions): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $this->stream, + template: $this->template, + component: $this->component, + redactions: array_merge($this->redactions, $redactions), + correlation: $this->correlation, + minimumLevel: $this->minimumLevel + ); + } + + /** + * Returns a copy of the builder with the correlation replaced. + * + * @param Correlation $correlation The correlation shared across log entries. + * @return StreamLoggerBuilder A copy of the builder with the correlation set. + */ + public function withCorrelation(Correlation $correlation): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $this->stream, + template: $this->template, + component: $this->component, + redactions: $this->redactions, + correlation: $correlation, + minimumLevel: $this->minimumLevel + ); + } + + /** + * Returns a copy of the builder with the minimum level replaced. + * + * @param LogLevel $minimumLevel The lowest severity that is written. + * @return StreamLoggerBuilder A copy of the builder with the minimum level set. + */ + public function withMinimumLevel(LogLevel $minimumLevel): StreamLoggerBuilder + { + return new StreamLoggerBuilder( + stream: $this->stream, + template: $this->template, + component: $this->component, + redactions: $this->redactions, + correlation: $this->correlation, + minimumLevel: $minimumLevel + ); + } +} diff --git a/src/StructuredLogger.php b/src/StructuredLogger.php deleted file mode 100644 index 670793d..0000000 --- a/src/StructuredLogger.php +++ /dev/null @@ -1,108 +0,0 @@ - $context The context data carried by the entry. - * @throws UnknownLogLevel If the level is outside the supported PSR-3 set. - */ - public function log(mixed $level, string|Stringable $message, array $context = []): void - { - $logLevel = LogLevel::fromPsrLevel(level: $level); - - if (!$logLevel->isAtLeast(threshold: $this->minimumLevel)) { - return; - } - - $formatted = $this->formatter->format( - key: (string)$message, - level: $logLevel, - payload: $this->redactions->applyTo(payload: $context), - context: $this->context - ); - - $this->stream->write(content: $formatted); - } - - public function withContext(LogContext $context): StructuredLogger - { - return new StructuredLogger( - stream: $this->stream, - context: $context, - formatter: $this->formatter, - redactions: $this->redactions, - minimumLevel: $this->minimumLevel - ); - } -} diff --git a/src/StructuredLoggerBuilder.php b/src/StructuredLoggerBuilder.php deleted file mode 100644 index b3e3a7d..0000000 --- a/src/StructuredLoggerBuilder.php +++ /dev/null @@ -1,148 +0,0 @@ -stream, - $this->context, - $this->template, - $this->component, - $this->minimumLevel, - ...$this->redactions - ); - } - - /** - * Returns a copy of the builder with the stream replaced. - * - * @param mixed $stream The stream the logger writes to. - * @return StructuredLoggerBuilder A copy of the builder with the stream set. - */ - public function withStream(mixed $stream): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $stream, - context: $this->context, - template: $this->template, - component: $this->component, - redactions: $this->redactions, - minimumLevel: $this->minimumLevel - ); - } - - /** - * Returns a copy of the builder with the context replaced. - * - * @param LogContext $context The context shared across log entries. - * @return StructuredLoggerBuilder A copy of the builder with the context set. - */ - public function withContext(LogContext $context): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $this->stream, - context: $context, - template: $this->template, - component: $this->component, - redactions: $this->redactions, - minimumLevel: $this->minimumLevel - ); - } - - /** - * Returns a copy of the builder with the template replaced. - * - * @param string $template The format template for rendering entries. - * @return StructuredLoggerBuilder A copy of the builder with the template set. - */ - public function withTemplate(string $template): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $this->stream, - context: $this->context, - template: $template, - component: $this->component, - redactions: $this->redactions, - minimumLevel: $this->minimumLevel - ); - } - - /** - * Returns a copy of the builder with the component replaced. - * - * @param string $component The component name identifying the log source. - * @return StructuredLoggerBuilder A copy of the builder with the component set. - */ - public function withComponent(string $component): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $this->stream, - context: $this->context, - template: $this->template, - component: $component, - redactions: $this->redactions, - minimumLevel: $this->minimumLevel - ); - } - - /** - * Returns a copy of the builder with the given redactions appended. - * - * @param Redaction ...$redactions The redaction strategies to apply to log data. - * @return StructuredLoggerBuilder A copy of the builder with the redactions appended. - */ - public function withRedactions(Redaction ...$redactions): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $this->stream, - context: $this->context, - template: $this->template, - component: $this->component, - redactions: array_merge($this->redactions, $redactions), - minimumLevel: $this->minimumLevel - ); - } - - /** - * Returns a copy of the builder with the minimum level replaced. - * - * @param LogLevel $minimumLevel The lowest severity that is written, quieter entries are discarded. - * @return StructuredLoggerBuilder A copy of the builder with the minimum level set. - */ - public function withMinimumLevel(LogLevel $minimumLevel): StructuredLoggerBuilder - { - return new StructuredLoggerBuilder( - stream: $this->stream, - context: $this->context, - template: $this->template, - component: $this->component, - redactions: $this->redactions, - minimumLevel: $minimumLevel - ); - } -} diff --git a/src/TelemetryLogger.php b/src/TelemetryLogger.php new file mode 100644 index 0000000..7bca7d1 --- /dev/null +++ b/src/TelemetryLogger.php @@ -0,0 +1,103 @@ +Two kinds of record travel here, and they never share a line. An entry is narrative and carries + * a severity; a metric is a measurement and carries none, so a threshold raised to quiet the logs + * does not stop a series. What the logger knows about itself rides on both, which is what lets a + * series be traced back to the surrounding entries.

+ *

Build a {@see StreamLogger} instead when nothing is measured. This type exists so that + * having metrics is a choice made once, where the logger is assembled, and not an argument repeated + * at every call.

+ */ +final readonly class TelemetryLogger implements Logger +{ + use LoggerTrait; + + private function __construct( + private StreamLogger $logger, + private MetricWriter $writer, + private ?Correlation $correlation + ) { + } + + /** + * Creates a TelemetryLogger from what its builder describes. + * + * @param TelemetryLoggerBuilder $builder The metric format, plus everything the log side takes. + * @return TelemetryLogger The created logger instance. + */ + public static function from(TelemetryLoggerBuilder $builder): TelemetryLogger + { + return new TelemetryLogger( + logger: $builder->logger->build(), + writer: new MetricWriter( + format: $builder->format, + stream: LogStream::from(resource: $builder->logger->stream), + component: $builder->logger->component, + redactions: Redactions::createFrom(elements: $builder->logger->redactions) + ), + correlation: $builder->logger->correlation + ); + } + + /** + * Creates a TelemetryLoggerBuilder around the format that renders every metric it writes. + * + *

The format is taken here rather than at build time because a logger of this kind without one + * has nothing to measure with, and an object that cannot be valid should not be reachable.

+ * + * @param MetricFormat $format The format rendering a metric for the backend that reads it. + * @return TelemetryLoggerBuilder A new builder for configuring a TelemetryLogger. + */ + public static function builder(MetricFormat $format): TelemetryLoggerBuilder + { + return TelemetryLoggerBuilder::from(format: $format); + } + + /** + * Writes a log entry when its severity reaches the configured minimum level. + * + * @param mixed $level The severity, as received by the PSR-3 contract. + * @param string|Stringable $message The message key identifying the event. + * @param array $context The context data carried by the entry. + * @throws UnknownLogLevel If the level is outside the supported PSR-3 set. + */ + public function log(mixed $level, string|Stringable $message, array $context = []): void + { + $this->logger->log($level, $message, $context); + } + + /** + * Writes a metric, in the shape the configured format renders it. + * + * @param Metric $metric The metric to write. + */ + public function metric(Metric $metric): void + { + $this->writer->write(metric: $metric, correlation: $this->correlation); + } + + public function withCorrelation(Correlation $correlation): TelemetryLogger + { + return new TelemetryLogger( + logger: $this->logger->withCorrelation(correlation: $correlation), + writer: $this->writer, + correlation: $correlation + ); + } +} diff --git a/src/TelemetryLoggerBuilder.php b/src/TelemetryLoggerBuilder.php new file mode 100644 index 0000000..4ee8dc3 --- /dev/null +++ b/src/TelemetryLoggerBuilder.php @@ -0,0 +1,126 @@ +What describes the log side is a {@see StreamLoggerBuilder}, so the two loggers are configured + * by the same vocabulary and the metric format is the only thing this one adds.

+ */ +final readonly class TelemetryLoggerBuilder +{ + private function __construct(public MetricFormat $format, public StreamLoggerBuilder $logger) + { + } + + /** + * Creates a TelemetryLoggerBuilder around the format that renders every metric it writes. + * + * @param MetricFormat $format The format rendering a metric for the backend that reads it. + * @return TelemetryLoggerBuilder A builder writing to standard error, with no redaction and no threshold. + */ + public static function from(MetricFormat $format): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder(format: $format, logger: StreamLoggerBuilder::default()); + } + + /** + * Builds a TelemetryLogger from what the builder describes. + * + * @return TelemetryLogger The configured logger instance. + */ + public function build(): TelemetryLogger + { + return TelemetryLogger::from(builder: $this); + } + + /** + * Returns a copy of the builder with the stream replaced. + * + * @param mixed $stream The stream both records are written to. + * @return TelemetryLoggerBuilder A copy of the builder with the stream set. + */ + public function withStream(mixed $stream): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withStream(stream: $stream) + ); + } + + /** + * Returns a copy of the builder with the format template replaced. + * + * @param string $template The format template applied to every entry. + * @return TelemetryLoggerBuilder A copy of the builder with the template set. + */ + public function withTemplate(string $template): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withTemplate(template: $template) + ); + } + + /** + * Returns a copy of the builder with the component replaced. + * + * @param string $component The component name identifying the source of both records. + * @return TelemetryLoggerBuilder A copy of the builder with the component set. + */ + public function withComponent(string $component): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withComponent(component: $component) + ); + } + + /** + * Returns a copy of the builder with the redactions added to the ones already described. + * + * @param Redaction ...$redactions The redaction strategies applied before writing. + * @return TelemetryLoggerBuilder A copy of the builder carrying the previous and the new redactions. + */ + public function withRedactions(Redaction ...$redactions): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withRedactions(...$redactions) + ); + } + + /** + * Returns a copy of the builder with the correlation replaced. + * + * @param Correlation $correlation The correlation shared across log entries and metrics. + * @return TelemetryLoggerBuilder A copy of the builder with the correlation set. + */ + public function withCorrelation(Correlation $correlation): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withCorrelation(correlation: $correlation) + ); + } + + /** + * Returns a copy of the builder with the minimum level replaced. + * + * @param LogLevel $minimumLevel The lowest severity that is written. + * @return TelemetryLoggerBuilder A copy of the builder with the minimum level set. + */ + public function withMinimumLevel(LogLevel $minimumLevel): TelemetryLoggerBuilder + { + return new TelemetryLoggerBuilder( + format: $this->format, + logger: $this->logger->withMinimumLevel(minimumLevel: $minimumLevel) + ); + } +} diff --git a/tests/Models/EmbeddedMetricPayload.php b/tests/Models/EmbeddedMetricPayload.php new file mode 100644 index 0000000..6e20919 --- /dev/null +++ b/tests/Models/EmbeddedMetricPayload.php @@ -0,0 +1,54 @@ +declaration()['Metrics'][0]['Unit']; + } + + public function metrics(): mixed + { + return $this->declaration()['Metrics']; + } + + public function timestamp(): mixed + { + return $this->envelope()['Timestamp']; + } + + public function namespaced(): mixed + { + return $this->declaration()['Namespace']; + } + + public function dimensions(): mixed + { + return $this->declaration()['Dimensions']; + } + + private function envelope(): array + { + return (array)$this->payload['_aws']; + } + + private function declaration(): array + { + $declarations = (array)$this->envelope()['CloudWatchMetrics']; + + return (array)$declarations[0]; + } +} diff --git a/tests/RedactionsTest.php b/tests/RedactionsTest.php deleted file mode 100644 index b79c6ab..0000000 --- a/tests/RedactionsTest.php +++ /dev/null @@ -1,497 +0,0 @@ -logStream = InMemoryStream::create(); - } - - protected function tearDown(): void - { - $this->logStream->close(); - } - - public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void - { - /** @Given a structured logger keeping the head and the tail of the phone */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'contact-service') - ->withRedactions( - VisibleEdgesRedaction::from( - mask: Mask::proportional(), - fields: ['phone'], - visiblePrefixLength: 5, - visibleSuffixLength: 4 - ) - ) - ->build(); - - /** @When logging with a phone field */ - $logger->info(message: 'contact.updated', context: ['phone' => '+5511999887766']); - - /** @Then both edges of the value stay visible and the middle is masked */ - self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents()); - } - - public function testRedactWhenWordwiseThenMasksEachWordApart(): void - { - /** @Given a structured logger masking each word of a name with a fixed-width mask */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'user-service') - ->withRedactions( - WordwiseRedaction::from( - mask: Mask::fixed(length: 3), - fields: ['name'], - visiblePrefixLength: 2 - ) - ) - ->build(); - - /** @When logging with a name made of several words */ - $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); - - /** @Then every word keeps its own visible prefix */ - self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents()); - } - - public function testRedactWhenFullMaskThenHidesTheValueLength(): void - { - /** @Given a structured logger masking two fields of different lengths with a fixed mask */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'audit-service') - ->withRedactions( - FullMaskRedaction::from(mask: Mask::fixed(length: 4), fields: ['user_agent', 'session_id']) - ) - ->build(); - - /** @When logging with both fields and an unrelated one */ - $logger->info(message: 'request.received', context: [ - 'user_agent' => 'Mozilla/5.0', - 'session_id' => 'abc', - 'route' => '/v1/users' - ]); - - /** @Then both masks have the same width regardless of the original length */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"user_agent":"****"', $output); - self::assertStringContainsString('"session_id":"****"', $output); - self::assertStringContainsString('"route":"/v1/users"', $output); - } - - public function testRedactWhenValueIsNumericThenMasksItAsText(): void - { - /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::default()) - ->build(); - - /** @When logging with a document decoded from JSON as an integer */ - $logger->info(message: 'kyc.verified', context: ['document' => 12345678900, 'amount' => 100]); - - /** @Then the numeric document is masked as text and the unrelated number is preserved */ - self::assertStringContainsString('data={"document":"********900","amount":100}', $this->logStream->contents()); - } - - public function testRedactWhenListOfValuesThenMasksEachElement(): void - { - /** @Given a structured logger with phone redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'notification-service') - ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4)) - ->build(); - - /** @When logging with a sensitive field holding a list of values */ - $logger->info(message: 'sms.queued', context: ['phone' => ['+5511999887766', '+5521988776655']]); - - /** @Then every element of the list is masked */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('data={"phone":["**********7766","**********6655"]}', $output); - self::assertStringNotContainsString('+5511999887766', $output); - } - - public function testRedactWhenValueIsNullThenLeavesItUntouched(): void - { - /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::default()) - ->build(); - - /** @When logging with a sensitive field holding no value */ - $logger->info(message: 'kyc.skipped', context: ['document' => null, 'status' => 'pending']); - - /** @Then the absent value is written as is */ - self::assertStringContainsString('data={"document":null,"status":"pending"}', $this->logStream->contents()); - } - - public function testRedactWhenWildcardFieldThenMasksEveryMatch(): void - { - /** @Given a structured logger with a name redaction targeting a field name pattern */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'messaging-service') - ->withRedactions(NameRedaction::from(fields: ['*_name'], visiblePrefixLength: 2)) - ->build(); - - /** @When logging with a matching field, a field that does not match, and a list */ - $logger->info(message: 'message.dispatched', context: [ - 'client_name' => 'João Silva', - 'name' => 'Maria', - 'tags' => ['first', 'retry'] - ]); - - /** @Then only the matching field is masked */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"client_name":"Jo********"', $output); - self::assertStringContainsString('"name":"Maria"', $output); - - /** @And the numeric keys of the list are matched against the pattern without failing */ - self::assertStringContainsString('"tags":["first","retry"]', $output); - } - - public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void - { - /** @Given a structured logger masking the value field only under a document parent */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions( - ScopedRedaction::under( - parent: 'document', - redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) - ) - ) - ->build(); - - /** @When logging with the same field name inside and outside the scope */ - $logger->info(message: 'payment.created', context: [ - 'document' => ['type' => 'cpf', 'value' => '12345678901'], - 'metadata' => ['value' => 'operational-marker'], - 'charge' => ['document' => ['value' => '98765432100']] - ]); - - /** @Then the value inside the scope is masked */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"document":{"type":"cpf","value":"*********01"}', $output); - - /** @And the value outside the scope is preserved */ - self::assertStringContainsString('"metadata":{"value":"operational-marker"}', $output); - - /** @And the scope is honored at any depth */ - self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output); - } - - public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void - { - /** @Given a structured logger masking eleven-digit runs found in any text */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'error-service') - ->withRedactions(PatternRedaction::from(pattern: '/\d{11}/', replacement: '[REDACTED]')) - ->build(); - - /** @When logging an exception message and a URI carrying the document */ - $logger->error(message: 'request.failed', context: [ - 'message' => 'Document 12345678901 is invalid.', - 'attempts' => 3, - 'nested' => ['uri' => '/clients/12345678901'] - ]); - - /** @Then the match inside the free text is replaced */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"message":"Document [REDACTED] is invalid."', $output); - - /** @And the match inside the nested URI is replaced */ - self::assertStringContainsString('"nested":{"uri":"/clients/[REDACTED]"}', $output); - - /** @And values that are not text are left alone */ - self::assertStringContainsString('"attempts":3', $output); - } - - public function testRedactWhenAllowListThenMasksEveryFieldOutsideIt(): void - { - /** @Given a structured logger allowing only the city and the state under the address */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions( - ScopedRedaction::under( - parent: 'address', - redaction: AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city', 'state']) - ) - ) - ->build(); - - /** @When logging with an address and a sibling field outside the scope */ - $logger->info(message: 'payment.created', context: [ - 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example'], - 'name' => 'Maria' - ]); - - /** @Then the allowed fields survive and everything else in the scope is masked */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"city":"São Paulo","state":"BR-SP","street":"***"', $output); - - /** @And fields outside the scope are untouched */ - self::assertStringContainsString('"name":"Maria"', $output); - } - - public function testRedactWhenScopeHoldsScalarThenLeavesItUntouched(): void - { - /** @Given a structured logger masking the value field only under a document parent */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions( - ScopedRedaction::under( - parent: 'document', - redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) - ) - ) - ->build(); - - /** @When logging with the scope parent holding a scalar instead of a sub payload */ - $logger->info(message: 'payment.created', context: ['document' => 'plain-value']); - - /** @Then the scalar is written as is */ - self::assertStringContainsString('data={"document":"plain-value"}', $this->logStream->contents()); - } - - public function testRedactWhenCommonSecretsThenMasksEverySecretField(): void - { - /** @Given a structured logger with the common secrets redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'auth-service') - ->withRedactions(FullMaskRedaction::commonSecrets()) - ->build(); - - /** @When logging with one field per secret naming convention */ - $logger->info(message: 'auth.check', context: [ - 'user_id' => 'u-1', - 'credentials' => 'basic', - 'access_token' => 'at-1', - 'authorization' => 'Bearer x', - 'client_secret' => 'cs-1', - 'stripe_api_key' => 'ak-1', - 'rsa_private_key' => 'pk-1', - 'current_password' => 'pw-1' - ]); - - /** @Then every secret field is fully masked */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"credentials":"********"', $output); - self::assertStringContainsString('"access_token":"********"', $output); - self::assertStringContainsString('"authorization":"********"', $output); - self::assertStringContainsString('"client_secret":"********"', $output); - self::assertStringContainsString('"stripe_api_key":"********"', $output); - self::assertStringContainsString('"rsa_private_key":"********"', $output); - self::assertStringContainsString('"current_password":"********"', $output); - - /** @And fields outside the convention are preserved */ - self::assertStringContainsString('"user_id":"u-1"', $output); - } - - public function testRedactWhenFieldsRemovedThenDropsThemFromTheEntry(): void - { - /** @Given a structured logger dropping the trace and every token field */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'error-service') - ->withRedactions(RemovedFieldsRedaction::from(fields: ['trace', '*_token'])) - ->build(); - - /** @When logging with those fields at the root and nested */ - $logger->error(message: 'request.failed', context: [ - 'message' => 'boom', - 'trace' => '#0 stack', - 'nested' => ['access_token' => 'at-1', 'id' => '7'] - ]); - - /** @Then the dropped fields leave no trace in the entry */ - self::assertStringContainsString('data={"message":"boom","nested":{"id":"7"}}', $this->logStream->contents()); - } - - public function testRedactWhenSeparatorsKeptThenPreservesPunctuation(): void - { - /** @Given a structured logger masking a postal code while keeping its separators */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions( - VisibleEdgesRedaction::from( - mask: Mask::preservingSeparators(), - fields: ['postal_code', 'reference', 'coordinates'], - visiblePrefixLength: 3 - ) - ) - ->build(); - - /** @When logging with a formatted postal code, an accented reference, and a coordinate */ - $logger->info(message: 'address.saved', context: [ - 'postal_code' => '01310-100', - 'reference' => 'Rua Açaí, 42', - 'coordinates' => 'S 23° 33' - ]); - - /** @Then digits are hidden and the separator survives */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"postal_code":"013**-***"', $output); - - /** @And accented letters are hidden as single characters */ - self::assertStringContainsString('"reference":"Rua ****, **"', $output); - - /** @And symbols outside the letter and digit categories survive */ - self::assertStringContainsString('"coordinates":"S 2*° **"', $output); - } - - public function testRedactWhenMapOfValuesThenDescendsInsteadOfMasking(): void - { - /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'kyc-service') - ->withRedactions(DocumentRedaction::default()) - ->build(); - - /** @When logging with a sensitive field holding a map instead of a value */ - $logger->info(message: 'kyc.verified', context: [ - 'document' => ['type' => 'cnpj', 'value' => '12345678000199', 'issued_at' => '2020-01-01'] - ]); - - /** @Then the map is descended into, so the fields that carry their own meaning survive */ - self::assertStringContainsString( - 'data={"document":{"type":"cnpj","value":"12345678000199","issued_at":"2020-01-01"}}', - $this->logStream->contents() - ); - } - - public function testRedactWhenPatternIsInvalidThenThrowsInvalidPattern(): void - { - /** @Then an exception describing the rejected pattern is raised */ - $this->expectException(InvalidRedactionPattern::class); - - /** @And the message names the offending pattern */ - $this->expectExceptionMessage('Pattern is not a valid regular expression: /[unclosed/.'); - - /** @When building a redaction from a pattern the engine cannot compile */ - PatternRedaction::from(pattern: '/[unclosed/', replacement: '[REDACTED]'); - } - - public function testRedactWhenPrefixIsNegativeThenThrowsNegativeLength(): void - { - /** @Then an exception describing the rejected configuration is raised */ - $this->expectException(NegativeVisibleLength::class); - - /** @And the message names both visible lengths */ - $this->expectExceptionMessage('Visible length cannot be negative, got prefix -1 and suffix 4.'); - - /** @When building a redaction that leaves a negative number of leading characters visible */ - VisibleEdgesRedaction::from( - mask: Mask::proportional(), - fields: ['phone'], - visiblePrefixLength: -1, - visibleSuffixLength: 4 - ); - } - - public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void - { - /** @Then an exception describing the rejected configuration is raised */ - $this->expectException(NegativeVisibleLength::class); - - /** @And the message names both visible lengths */ - $this->expectExceptionMessage('Visible length cannot be negative, got prefix 2 and suffix -1.'); - - /** @When building a redaction that leaves a negative number of trailing characters visible */ - VisibleEdgesRedaction::from( - mask: Mask::proportional(), - fields: ['phone'], - visiblePrefixLength: 2, - visibleSuffixLength: -1 - ); - } - - public function testRedactWhenWordwiseHasNoPrefixThenMasksEveryWordHead(): void - { - /** @Given a structured logger masking each word down to its last character */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'user-service') - ->withRedactions( - WordwiseRedaction::from( - mask: Mask::proportional(), - fields: ['name'], - visibleSuffixLength: 1 - ) - ) - ->build(); - - /** @When logging with a name made of several words */ - $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); - - /** @Then every word keeps only its trailing character */ - self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents()); - } - - public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void - { - /** @Given a structured logger allowing only the city field */ - $logger = StructuredLogger::create() - ->withStream(stream: $this->logStream->handle()) - ->withComponent(component: 'payment-service') - ->withRedactions(AllowedFieldsRedaction::from(mask: Mask::fixed(length: 3), fields: ['city'])) - ->build(); - - /** @When logging with an absent value and a nested allowed field */ - $logger->info(message: 'address.saved', context: [ - 'city' => 'São Paulo', - 'street' => null, - 'nested' => ['city' => 'Rio', 'label' => 'x'] - ]); - - /** @Then the absent value is written as is and the nested allow list still applies */ - $output = $this->logStream->contents(); - - self::assertStringContainsString('"city":"São Paulo","street":null', $output); - self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output); - } -} diff --git a/tests/Unit/EmbeddedMetricFormatTest.php b/tests/Unit/EmbeddedMetricFormatTest.php new file mode 100644 index 0000000..9fe8f91 --- /dev/null +++ b/tests/Unit/EmbeddedMetricFormatTest.php @@ -0,0 +1,217 @@ +withField(name: 'tenant_id', value: 'abc-123') + ->withField(name: 'attempt', value: 2); + + /** @When rendering it */ + $payload = EmbeddedMetricFormat::default()->format(metric: $metric); + + /** @Then they are in the record and the dimension set stays empty */ + self::assertSame('abc-123', $payload['tenant_id']); + self::assertSame(2, $payload['attempt']); + self::assertSame([[]], EmbeddedMetricPayload::from(payload: $payload)->dimensions()); + } + + public function testFormatWhenTimestampRenderedThenIsInMilliseconds(): void + { + /** @Given a counter */ + $metric = Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule'); + + /** @When rendering it */ + $timestamp = EmbeddedMetricPayload::from( + payload: EmbeddedMetricFormat::default()->format(metric: $metric) + )->timestamp(); + + /** @Then the timestamp is the current instant expressed in milliseconds */ + $secondsNow = time(); + $lowerBound = (($secondsNow - 1) * 1000); + $upperBound = (($secondsNow + 2) * 1000); + + self::assertIsInt($timestamp); + self::assertGreaterThanOrEqual($lowerBound, $timestamp); + self::assertLessThan($upperBound, $timestamp); + } + + #[DataProvider('unitProvider')] + + public function testFormatWhenUnitGivenThenSpellsItAsCloudWatchReadsIt(MetricUnit $unit, string $token): void + { + /** @Given a measurement in one of the units the neutral vocabulary carries */ + $metric = Metric::of(name: 'OfferResponseTime', namespace: 'Acme/Waitlist') + ->withUnit(unit: $unit) + ->withValue(value: 12.5); + + /** @When rendering it */ + $payload = EmbeddedMetricFormat::default()->format(metric: $metric); + + /** @Then the declaration carries the CloudWatch token, and the value keeps its precision */ + self::assertSame( + [['Name' => 'OfferResponseTime', 'Unit' => $token]], + EmbeddedMetricPayload::from(payload: $payload)->metrics() + ); + self::assertSame(12.5, $payload['OfferResponseTime']); + } + + public function testFormatWhenNoNameIsRefusedThenAnyDimensionIsAccepted(): void + { + /** @Given a format that was told to refuse nothing */ + $format = EmbeddedMetricFormat::default(); + + /** @And a counter broken down by a name another consumer might refuse */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withDimension(name: 'tenant_id', value: 'abc-123'); + + /** @When rendering it */ + $payload = $format->format(metric: $metric); + + /** @Then nothing is refused, because which names are unbounded is not the library's to know */ + self::assertSame([['tenant_id']], EmbeddedMetricPayload::from(payload: $payload)->dimensions()); + } + + public function testFormatWhenNoDimensionThenDeclaresTheEmptyDimensionSet(): void + { + /** @Given a counter with no dimension and no field */ + $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist'); + + /** @When rendering it */ + $payload = EmbeddedMetricFormat::default()->format(metric: $metric); + + /** @Then the metric declares one empty dimension set and counts one */ + $record = EmbeddedMetricPayload::from(payload: $payload); + + self::assertSame('Acme/Waitlist', $record->namespaced()); + self::assertSame([[]], $record->dimensions()); + self::assertSame([['Name' => 'OfferAccepted', 'Unit' => 'Count']], $record->metrics()); + self::assertSame(1, $payload['OfferAccepted']); + } + + public function testFormatWhenDimensionDeclaredThenIsRepeatedAsAFieldOfTheRecord(): void + { + /** @Given a counter broken down by a bounded dimension */ + $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription') + ->withDimension(name: 'subscription_type', value: 'saas'); + + /** @When rendering it */ + $payload = EmbeddedMetricFormat::default()->format(metric: $metric); + + /** @Then the dimension is declared and repeated at the root, which is what EMF resolves */ + self::assertSame([['subscription_type']], EmbeddedMetricPayload::from(payload: $payload)->dimensions()); + self::assertSame('saas', $payload['subscription_type']); + } + + public function testFormatWhenAFieldIsNamedAfterTheEnvelopeThenTheEnvelopeSurvives(): void + { + /** @Given a counter carrying a field whose name collides with the envelope key */ + $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist') + ->withField(name: '_aws', value: 'oops'); + + /** @When rendering it */ + $payload = EmbeddedMetricFormat::default()->format(metric: $metric); + + /** @Then the envelope wins, so the record is still a metric and not a plain log line */ + self::assertSame('Acme/Waitlist', EmbeddedMetricPayload::from(payload: $payload)->namespaced()); + } + + public function testWithUnboundedDimensionsWhenCalledTwiceThenTheEarlierNameSurvives(): void + { + /** @Given a format told about one name and then, in another call, about a different one */ + $format = EmbeddedMetricFormat::default() + ->withUnboundedDimensions('user_id') + ->withUnboundedDimensions('tenant_id'); + + /** @And a counter broken down by the name of the first call */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withDimension(name: 'user_id', value: 'abc-123'); + + /** @Then the earlier list is added to, and not replaced by the later one */ + $this->expectException(UnboundedDimension::class); + + /** @When rendering it */ + $format->format(metric: $metric); + } + + public function testWithUnboundedDimensionsWhenDerivedThenTheOriginalStillAcceptsTheName(): void + { + /** @Given a format that refuses nothing */ + $format = EmbeddedMetricFormat::default(); + + /** @And a copy of it that refuses a name */ + $derived = $format->withUnboundedDimensions('tenant_id'); + + /** @And a counter broken down by that name */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withDimension(name: 'tenant_id', value: 'abc-123'); + + /** @When rendering through the original */ + $payload = $format->format(metric: $metric); + + /** @Then the copy is another instance and the original was not mutated by it */ + self::assertNotSame($format, $derived); + self::assertSame([['tenant_id']], EmbeddedMetricPayload::from(payload: $payload)->dimensions()); + } + + #[DataProvider('unboundedDimensionProvider')] + + public function testFormatWhenDimensionWasDeclaredUnboundedThenRefusesAndNamesTheAlternative( + string $dimension + ): void { + /** @Given a format told which names the consumer treats as unbounded */ + $format = EmbeddedMetricFormat::default()->withUnboundedDimensions('user_id', 'tenant_id'); + + /** @And a counter broken down by one of them */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withDimension(name: $dimension, value: 'anything'); + + /** @Then the refusal names the dimension and points at the field */ + $this->expectException(UnboundedDimension::class); + $this->expectExceptionMessage( + sprintf( + 'The dimension <%s> was declared unbounded. Emit it as a field of the same record instead.', + $dimension + ) + ); + + /** @When rendering it */ + $format->format(metric: $metric); + } + + public static function unitProvider(): iterable + { + $tokens = [ + 'bit' => 'Bits', + '1' => 'None', + 'By' => 'Bytes', + '{count}' => 'Count', + '%' => 'Percent', + 's' => 'Seconds' + ]; + + foreach (MetricUnit::cases() as $unit) { + yield $unit->value => ['unit' => $unit, 'token' => $tokens[$unit->value]]; + } + } + + public static function unboundedDimensionProvider(): iterable + { + yield 'user identifier' => ['dimension' => 'user_id']; + yield 'tenant identifier' => ['dimension' => 'tenant_id']; + } +} diff --git a/tests/InMemoryLoggerTest.php b/tests/Unit/InMemoryLoggerTest.php similarity index 66% rename from tests/InMemoryLoggerTest.php rename to tests/Unit/InMemoryLoggerTest.php index 468ff31..c971e1a 100644 --- a/tests/InMemoryLoggerTest.php +++ b/tests/Unit/InMemoryLoggerTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Logger; +namespace Test\TinyBlocks\Logger\Unit; use PHPUnit\Framework\TestCase; +use TinyBlocks\Logger\Correlation; use TinyBlocks\Logger\Exceptions\UnknownLogLevel; use TinyBlocks\Logger\InMemoryLogger; -use TinyBlocks\Logger\LogContext; final class InMemoryLoggerTest extends TestCase { @@ -23,31 +23,6 @@ public function testEntriesWhenNothingLoggedThenHoldsNoEntry(): void self::assertTrue($entries->isEmpty()); } - public function testLogWhenContextDerivedThenBothInstancesSeeIt(): void - { - /** @Given an in-memory logger */ - $logger = InMemoryLogger::create(); - - /** @And a contextual logger derived from it */ - $contextual = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); - - /** @When logging through the contextual logger */ - $contextual->error(message: 'payment.failed'); - - /** @Then the entry carries the correlation context and is visible from the original logger */ - self::assertSame( - [ - [ - 'key' => 'payment.failed', - 'level' => 'ERROR', - 'context' => 'req-abc-123', - 'payload' => [] - ] - ], - $logger->entries()->toArray() - ); - } - public function testLogWhenEntryRecordedThenKeepsLevelAndPayload(): void { /** @Given an in-memory logger */ @@ -60,10 +35,10 @@ public function testLogWhenEntryRecordedThenKeepsLevelAndPayload(): void self::assertSame( [ [ - 'key' => 'user.created', - 'level' => 'INFO', - 'context' => null, - 'payload' => ['document' => '12345678900'] + 'key' => 'user.created', + 'level' => 'INFO', + 'payload' => ['document' => '12345678900'], + 'correlation' => null ] ], $logger->entries()->toArray() @@ -81,4 +56,29 @@ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void /** @When logging at a level outside the supported set */ $logger->log('not-a-level', 'some.key'); } + + public function testLogWhenCorrelationDerivedThenBothInstancesSeeIt(): void + { + /** @Given an in-memory logger */ + $logger = InMemoryLogger::create(); + + /** @And a correlated logger derived from it */ + $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')); + + /** @When logging through the correlated logger */ + $correlated->error(message: 'payment.failed'); + + /** @Then the entry carries the correlation and is visible from the original logger */ + self::assertSame( + [ + [ + 'key' => 'payment.failed', + 'level' => 'ERROR', + 'payload' => [], + 'correlation' => 'req-abc-123' + ] + ], + $logger->entries()->toArray() + ); + } } diff --git a/tests/InMemoryStream.php b/tests/Unit/InMemoryStream.php similarity index 95% rename from tests/InMemoryStream.php rename to tests/Unit/InMemoryStream.php index 4467687..8ac4482 100644 --- a/tests/InMemoryStream.php +++ b/tests/Unit/InMemoryStream.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Logger; +namespace Test\TinyBlocks\Logger\Unit; final class InMemoryStream { diff --git a/tests/LogLevelTest.php b/tests/Unit/LogLevelTest.php similarity index 95% rename from tests/LogLevelTest.php rename to tests/Unit/LogLevelTest.php index 5ebfb56..953fea4 100644 --- a/tests/LogLevelTest.php +++ b/tests/Unit/LogLevelTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Logger; +namespace Test\TinyBlocks\Logger\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use TinyBlocks\Logger\LogLevel; -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\StreamLogger; final class LogLevelTest extends TestCase { @@ -26,7 +26,7 @@ protected function tearDown(): void public function testLogWhenAtMinimumLevelThenWritesTheEntry(): void { /** @Given a structured logger that discards anything below warning */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'order-service') ->withMinimumLevel(minimumLevel: LogLevel::WARNING) @@ -54,7 +54,7 @@ public function testSeverityWhenLevelGivenThenReturnsItsRank(LogLevel $level, in public function testLogWhenBelowMinimumLevelThenWritesNothing(): void { /** @Given a structured logger that discards anything below warning */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'order-service') ->withMinimumLevel(minimumLevel: LogLevel::WARNING) @@ -82,7 +82,7 @@ public function testIsAtLeastWhenSameLevelThenReachesThreshold(): void public function testLogWhenLevelIsLowercaseThenResolvesTheLevel(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'order-service') ->build(); diff --git a/tests/Unit/MetricTest.php b/tests/Unit/MetricTest.php new file mode 100644 index 0000000..a9f9f8e --- /dev/null +++ b/tests/Unit/MetricTest.php @@ -0,0 +1,202 @@ +name->value); + self::assertSame(MetricUnit::COUNT, $metric->unit); + self::assertSame(1, $metric->value); + self::assertSame([], $metric->fields); + self::assertSame('Acme/Waitlist', $metric->namespace->value); + self::assertSame([], $metric->dimensions); + } + + public function testWithFieldWhenTheNameIsBlankThenRefusesIt(): void + { + /** @Given a metric */ + $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist'); + + /** @Then a field with no name is refused, for the same reason */ + $this->expectException(BlankMetricIdentifier::class); + $this->expectExceptionMessage('The metric field name cannot be blank.'); + + /** @When carrying it */ + $metric->withField(name: ' ', value: 'abc-123'); + } + + public function testWithDimensionWhenTheNameIsBlankThenRefusesIt(): void + { + /** @Given a metric */ + $metric = Metric::of(name: 'OfferAccepted', namespace: 'Acme/Waitlist'); + + /** @Then a dimension with no name is refused, because it resolves to nothing downstream */ + $this->expectException(BlankMetricIdentifier::class); + $this->expectExceptionMessage('The metric dimension name cannot be blank.'); + + /** @When declaring it */ + $metric->withDimension(name: ' ', value: 'saas'); + } + + #[DataProvider('blankIdentifierProvider')] + + public function testOfWhenAnIdentifierIsBlankThenRefusesAndNamesIt( + string $name, + string $namespace, + string $identifier + ): void { + /** @Given a name or a namespace that carries nothing */ + /** @Then the refusal names which one is blank */ + $this->expectException(BlankMetricIdentifier::class); + $this->expectExceptionMessage(sprintf('The metric %s cannot be blank.', $identifier)); + + /** @When creating the metric */ + Metric::of(name: $name, namespace: $namespace); + } + + public function testWithDimensionWhenTwoAddedThenBothSurviveInOrder(): void + { + /** @Given a counter */ + $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription'); + + /** @When adding two dimensions in sequence */ + $derived = $metric + ->withDimension(name: 'subscription_type', value: 'saas') + ->withDimension(name: 'billing_cycle', value: 'monthly'); + + /** @Then the second does not replace the first, and the original carries neither */ + self::assertSame('saas', $derived->dimensions['subscription_type']->value); + self::assertSame('monthly', $derived->dimensions['billing_cycle']->value); + self::assertSame([], $metric->dimensions); + } + + public function testOfWhenMeasuredThenDerivesFromTheCounterItStartsAs(): void + { + /** @Given a measurement that is not a count */ + /** @When deriving it from the only factory */ + $metric = Metric::of(name: 'OfferResponseTime', namespace: 'Acme/Waitlist') + ->withUnit(unit: MetricUnit::SECONDS) + ->withValue(value: 12.5); + + /** @Then both survive, and a duration is seconds and never a prefixed unit */ + self::assertSame(12.5, $metric->value); + self::assertSame(MetricUnit::SECONDS, $metric->unit); + } + + public function testWithDimensionWhenNameRepeatedThenTheLastValueWins(): void + { + /** @Given a counter already broken down by a dimension */ + $metric = Metric::of(name: 'SubscriptionCanceled', namespace: 'Acme/Subscription') + ->withDimension(name: 'subscription_type', value: 'saas'); + + /** @When the same name is declared again */ + $derived = $metric->withDimension(name: 'subscription_type', value: 'b2c'); + + /** @Then the copy carries the last value, without duplicating the name */ + self::assertSame(['subscription_type'], array_keys($derived->dimensions)); + self::assertSame('b2c', $derived->dimensions['subscription_type']->value); + } + + public function testWithDimensionWhenTheNameIsAlreadyAFieldThenRefusesIt(): void + { + /** @Given a counter already carrying a field */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withField(name: 'plan', value: 'saas'); + + /** @Then the refusal names the identifier both would land under, in this order too */ + $this->expectException(DuplicateMetricIdentifier::class); + $this->expectExceptionMessage( + 'The name is carried by a field and by a dimension at once. Both land at the root of the ' + . 'same record, so one would silently replace the other.' + ); + + /** @When declaring a dimension under the same name */ + $metric->withDimension(name: 'plan', value: 'b2c'); + } + + public function testWithFieldWhenTheNameIsAlreadyADimensionThenRefusesIt(): void + { + /** @Given a counter already broken down by a dimension */ + $metric = Metric::of(name: 'OfferDeclined', namespace: 'Acme/Waitlist') + ->withDimension(name: 'plan', value: 'saas'); + + /** @Then the refusal names the identifier both would land under */ + $this->expectException(DuplicateMetricIdentifier::class); + $this->expectExceptionMessage( + 'The name is carried by a field and by a dimension at once. Both land at the root of the ' + . 'same record, so one would silently replace the other.' + ); + + /** @When carrying a field under the same name */ + $metric->withField(name: 'plan', value: 'b2c'); + } + + public function testWithFieldWhenGranularThenTravelsAsFieldAndNotAsDimension(): void + { + /** @Given a counter */ + $metric = Metric::of(name: 'OfferExpired', namespace: 'Acme/Waitlist'); + + /** @When carrying granular attributes */ + $derived = $metric + ->withField(name: 'tenant_id', value: 'abc-123') + ->withField(name: 'attempt', value: 2); + + /** @Then they are fields, the dimension set stays empty, and the original carries neither */ + self::assertSame('abc-123', $derived->fields['tenant_id']->value); + self::assertSame(2, $derived->fields['attempt']->value); + self::assertSame([], $derived->dimensions); + self::assertSame([], $metric->fields); + } + + public function testWithUnitWhenReplacedThenCopyCarriesItAndOriginalIsUnchanged(): void + { + /** @Given a counter */ + $metric = Metric::of(name: 'AppointmentDuration', namespace: 'Acme/Schedule'); + + /** @When deriving a copy in another unit */ + $derived = $metric->withUnit(unit: MetricUnit::SECONDS); + + /** @Then the copy carries it and the original stays a count */ + self::assertSame(MetricUnit::SECONDS, $derived->unit); + self::assertSame(MetricUnit::COUNT, $metric->unit); + } + + public function testWithValueWhenReplacedThenCopyCarriesItAndOriginalIsUnchanged(): void + { + /** @Given a counter */ + $metric = Metric::of(name: 'AppointmentCompleted', namespace: 'Acme/Schedule'); + + /** @When deriving a copy with another value */ + $derived = $metric->withValue(value: 7); + + /** @Then the copy carries it and the original still counts one */ + self::assertSame(7, $derived->value); + self::assertSame(1, $metric->value); + } + + public static function blankIdentifierProvider(): array + { + return [ + 'empty name' => ['', 'Acme/Waitlist', 'name'], + 'blank name' => [' ', 'Acme/Waitlist', 'name'], + 'empty namespace' => ['OfferAccepted', '', 'namespace'], + 'blank namespace' => ['OfferAccepted', "\t", 'namespace'] + ]; + } +} diff --git a/tests/Unit/RedactionsTest.php b/tests/Unit/RedactionsTest.php new file mode 100644 index 0000000..e586829 --- /dev/null +++ b/tests/Unit/RedactionsTest.php @@ -0,0 +1,828 @@ +logStream = InMemoryStream::create(); + } + + protected function tearDown(): void + { + $this->logStream->close(); + } + + public function testRedactWhenVisibleEdgesThenKeepsBothEnds(): void + { + /** @Given a structured logger keeping the head and the tail of the phone */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'contact-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['phone'], + visibility: Visibility::edges(prefixLength: 5, suffixLength: 4) + ) + ) + ->build(); + + /** @When logging with a phone field */ + $logger->info(message: 'contact.updated', context: ['phone' => '+5511999887766']); + + /** @Then both edges of the value stay visible and the middle is masked */ + self::assertStringContainsString('data={"phone":"+5511*****7766"}', $this->logStream->contents()); + } + + public function testRedactWhenVisibleWordsThenMasksEachWordApart(): void + { + /** @Given a structured logger masking each word of a name with a fixed-width mask */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::fixed(length: 3), + fields: ['name'], + visibility: Visibility::words(prefixLength: 2) + ) + ) + ->build(); + + /** @When logging with a name made of several words */ + $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); + + /** @Then every word keeps its own visible prefix */ + self::assertStringContainsString('data={"name":"Gu*** Fr***"}', $this->logStream->contents()); + } + + public function testRedactWhenFullMaskThenHidesTheValueLength(): void + { + /** @Given a structured logger masking two fields of different lengths with a fixed mask */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'audit-service') + ->withRedactions( + GenericRedaction::masking(mask: Mask::fixed(length: 4), fields: ['user_agent', 'session_id']) + ) + ->build(); + + /** @When logging with both fields and an unrelated one */ + $logger->info(message: 'request.received', context: [ + 'user_agent' => 'Mozilla/5.0', + 'session_id' => 'abc', + 'route' => '/v1/users' + ]); + + /** @Then both masks have the same width regardless of the original length */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"user_agent":"****"', $output); + self::assertStringContainsString('"session_id":"****"', $output); + self::assertStringContainsString('"route":"/v1/users"', $output); + } + + public function testRedactWhenValueIsNumericThenMasksItAsText(): void + { + /** @Given a structured logger with document redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a document decoded from JSON as an integer */ + $logger->info(message: 'kyc.verified', context: ['document' => 12345678900, 'amount' => 100]); + + /** @Then the numeric document is masked as text and the unrelated number is preserved */ + self::assertStringContainsString('data={"document":"*********00","amount":100}', $this->logStream->contents()); + } + + public function testRedactWhenListOfValuesThenMasksEachElement(): void + { + /** @Given a structured logger with phone redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'notification-service') + ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4)) + ->build(); + + /** @When logging with a sensitive field holding a list of values */ + $logger->info(message: 'sms.queued', context: ['phone' => ['+5511999887766', '+5521988776655']]); + + /** @Then every element of the list is masked */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('data={"phone":["**********7766","**********6655"]}', $output); + self::assertStringNotContainsString('+5511999887766', $output); + } + + public function testRedactWhenValueIsNullThenLeavesItUntouched(): void + { + /** @Given a structured logger with document redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a sensitive field holding no value */ + $logger->info(message: 'kyc.skipped', context: ['document' => null, 'status' => 'pending']); + + /** @Then the absent value is written as is */ + self::assertStringContainsString('data={"document":null,"status":"pending"}', $this->logStream->contents()); + } + + public function testRedactWhenWildcardFieldThenMasksEveryMatch(): void + { + /** @Given a structured logger with a name redaction targeting a field name pattern */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'messaging-service') + ->withRedactions(NameRedaction::from(fields: ['*_name'], visiblePrefixLength: 2)) + ->build(); + + /** @When logging with a matching field, a field that does not match, and a list */ + $logger->info(message: 'message.dispatched', context: [ + 'client_name' => 'João Silva', + 'name' => 'Maria', + 'tags' => ['first', 'retry'] + ]); + + /** @Then only the matching field is masked */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"client_name":"Jo********"', $output); + self::assertStringContainsString('"name":"Maria"', $output); + + /** @And the numeric keys of the list are matched against the pattern without failing */ + self::assertStringContainsString('"tags":["first","retry"]', $output); + } + + public function testRedactWhenScopedThenMasksOnlyInsideTheScope(): void + { + /** @Given a structured logger masking the value field only under a document parent */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions( + GenericRedaction::under( + parent: 'document', + redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) + ) + ) + ->build(); + + /** @When logging with the same field name inside and outside the scope */ + $logger->info(message: 'payment.created', context: [ + 'document' => ['type' => 'cpf', 'value' => '12345678901'], + 'metadata' => ['value' => 'operational-marker'], + 'charge' => ['document' => ['value' => '98765432100']] + ]); + + /** @Then the value inside the scope is masked */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"document":{"type":"cpf","value":"*********01"}', $output); + + /** @And the value outside the scope is preserved */ + self::assertStringContainsString('"metadata":{"value":"operational-marker"}', $output); + + /** @And the scope is honored at any depth */ + self::assertStringContainsString('"charge":{"document":{"value":"*********00"}}', $output); + } + + public function testRedactWhenPatternMatchesTextThenMasksTheMatch(): void + { + /** @Given a structured logger masking eleven-digit runs found in any text */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'error-service') + ->withRedactions(GenericRedaction::replacing(pattern: '/\d{11}/', replacement: '[REDACTED]')) + ->build(); + + /** @When logging an exception message and a URI carrying the document */ + $logger->error(message: 'request.failed', context: [ + 'message' => 'Document 12345678901 is invalid.', + 'attempts' => 3, + 'nested' => ['uri' => '/clients/12345678901'] + ]); + + /** @Then the match inside the free text is replaced */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"message":"Document [REDACTED] is invalid."', $output); + + /** @And the match inside the nested URI is replaced */ + self::assertStringContainsString('"nested":{"uri":"/clients/[REDACTED]"}', $output); + + /** @And values that are not text are left alone */ + self::assertStringContainsString('"attempts":3', $output); + } + + public function testRedactWhenAllowListThenMasksEveryFieldOutsideIt(): void + { + /** @Given a structured logger allowing only the city and the state under the address */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions( + GenericRedaction::under( + parent: 'address', + redaction: GenericRedaction::keeping(fields: ['city', 'state'], mask: Mask::fixed(length: 3)) + ) + ) + ->build(); + + /** @When logging with an address and a sibling field outside the scope */ + $logger->info(message: 'payment.created', context: [ + 'address' => ['city' => 'São Paulo', 'state' => 'BR-SP', 'street' => 'Rua Example'], + 'name' => 'Maria' + ]); + + /** @Then the allowed fields survive and everything else in the scope is masked */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"city":"São Paulo","state":"BR-SP","street":"***"', $output); + + /** @And fields outside the scope are untouched */ + self::assertStringContainsString('"name":"Maria"', $output); + } + + public function testRedactWhenScopeHoldsScalarThenLeavesItUntouched(): void + { + /** @Given a structured logger masking the value field only under a document parent */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions( + GenericRedaction::under( + parent: 'document', + redaction: DocumentRedaction::from(fields: ['value'], visibleSuffixLength: 2) + ) + ) + ->build(); + + /** @When logging with the scope parent holding a scalar instead of a sub payload */ + $logger->info(message: 'payment.created', context: ['document' => 'plain-value']); + + /** @Then the scalar is written as is */ + self::assertStringContainsString('data={"document":"plain-value"}', $this->logStream->contents()); + } + + public function testRedactWhenCommonSecretsThenMasksEverySecretField(): void + { + /** @Given a structured logger with the common secrets redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'auth-service') + ->withRedactions(SecretRedaction::default()) + ->build(); + + /** @When logging with one field per secret naming convention */ + $logger->info(message: 'auth.check', context: [ + 'user_id' => 'u-1', + 'credentials' => 'basic', + 'access_token' => 'at-1', + 'authorization' => 'Bearer x', + 'client_secret' => 'cs-1', + 'stripe_api_key' => 'ak-1', + 'rsa_private_key' => 'pk-1', + 'current_password' => 'pw-1' + ]); + + /** @Then every secret field is fully masked */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"credentials":"********"', $output); + self::assertStringContainsString('"access_token":"********"', $output); + self::assertStringContainsString('"authorization":"********"', $output); + self::assertStringContainsString('"client_secret":"********"', $output); + self::assertStringContainsString('"stripe_api_key":"********"', $output); + self::assertStringContainsString('"rsa_private_key":"********"', $output); + self::assertStringContainsString('"current_password":"********"', $output); + + /** @And fields outside the convention are preserved */ + self::assertStringContainsString('"user_id":"u-1"', $output); + } + + public function testRedactWhenFieldsRemovedThenDropsThemFromTheEntry(): void + { + /** @Given a structured logger dropping the trace and every token field */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'error-service') + ->withRedactions(GenericRedaction::removing(fields: ['trace', '*_token'])) + ->build(); + + /** @When logging with those fields at the root and nested */ + $logger->error(message: 'request.failed', context: [ + 'message' => 'boom', + 'trace' => '#0 stack', + 'nested' => ['access_token' => 'at-1', 'id' => '7'] + ]); + + /** @Then the dropped fields leave no trace in the entry */ + self::assertStringContainsString('data={"message":"boom","nested":{"id":"7"}}', $this->logStream->contents()); + } + + public function testRedactWhenSeparatorsKeptThenPreservesPunctuation(): void + { + /** @Given a structured logger masking a postal code while keeping its separators */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::preservingSeparators(), + fields: ['postal_code', 'reference', 'coordinates'], + visibility: Visibility::edges(prefixLength: 3) + ) + ) + ->build(); + + /** @When logging with a formatted postal code, an accented reference, and a coordinate */ + $logger->info(message: 'address.saved', context: [ + 'postal_code' => '01310-100', + 'reference' => 'Rua Açaí, 42', + 'coordinates' => 'S 23° 33' + ]); + + /** @Then digits are hidden and the separator survives */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"postal_code":"013**-***"', $output); + + /** @And accented letters are hidden as single characters */ + self::assertStringContainsString('"reference":"Rua ****, **"', $output); + + /** @And symbols outside the letter and digit categories survive */ + self::assertStringContainsString('"coordinates":"S 2*° **"', $output); + } + + public function testRedactWhenMapOfValuesThenDescendsInsteadOfMasking(): void + { + /** @Given a structured logger with document redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'kyc-service') + ->withRedactions(DocumentRedaction::default()) + ->build(); + + /** @When logging with a sensitive field holding a map instead of a value */ + $logger->info(message: 'kyc.verified', context: [ + 'document' => ['type' => 'cnpj', 'value' => '12345678000199', 'issued_at' => '2020-01-01'] + ]); + + /** @Then the map is descended into, so the fields that carry their own meaning survive */ + self::assertStringContainsString( + 'data={"document":{"type":"cnpj","value":"12345678000199","issued_at":"2020-01-01"}}', + $this->logStream->contents() + ); + } + + public function testRedactWhenPatternIsInvalidThenThrowsInvalidPattern(): void + { + /** @Then an exception describing the rejected pattern is raised */ + $this->expectException(MalformedRedactionPattern::class); + + /** @And the message names the offending pattern */ + $this->expectExceptionMessage('Pattern is not a valid regular expression: /[unclosed/.'); + + /** @When building a redaction from a pattern the engine cannot compile */ + GenericRedaction::replacing(pattern: '/[unclosed/', replacement: '[REDACTED]'); + } + + public function testRedactWhenPrefixIsNegativeThenThrowsNegativeLength(): void + { + /** @Then an exception describing the rejected configuration is raised */ + $this->expectException(NegativeVisibleLength::class); + + /** @And the message names both visible lengths */ + $this->expectExceptionMessage('Visible length cannot be negative, got prefix -1 and suffix 4.'); + + /** @When building a redaction that leaves a negative number of leading characters visible */ + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['phone'], + visibility: Visibility::edges(prefixLength: -1, suffixLength: 4) + ); + } + + public function testRedactWhenSuffixIsNegativeThenThrowsNegativeLength(): void + { + /** @Then an exception describing the rejected configuration is raised */ + $this->expectException(NegativeVisibleLength::class); + + /** @And the message names both visible lengths */ + $this->expectExceptionMessage('Visible length cannot be negative, got prefix 2 and suffix -1.'); + + /** @When building a redaction that leaves a negative number of trailing characters visible */ + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['phone'], + visibility: Visibility::edges(prefixLength: 2, suffixLength: -1) + ); + } + + public function testRedactWhenVisibleWordsHaveNoPrefixThenMasksEveryWordHead(): void + { + /** @Given a structured logger masking each word down to its last character */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions( + GenericRedaction::masking( + mask: Mask::proportional(), + fields: ['name'], + visibility: Visibility::words(suffixLength: 1) + ) + ) + ->build(); + + /** @When logging with a name made of several words */ + $logger->info(message: 'user.created', context: ['name' => 'Gustavo Freze']); + + /** @Then every word keeps only its trailing character */ + self::assertStringContainsString('data={"name":"******o ****e"}', $this->logStream->contents()); + } + + public function testRedactWhenUrlCarriesAQueryStringThenOnlyThePathSurvives(): void + { + /** @Given a structured logger dropping every query string */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'client-gateway') + ->withRedactions(QueryStringRedaction::default()) + ->build(); + + /** @When logging a route, a URL quoted inside a message, and a path with no query string */ + $logger->info(message: 'request.received', context: [ + 'uri' => '/v1/clients?document=12345678900&page=2', + 'route' => '/v1/clients', + 'message' => 'GET https://api.example.com/v1/clients?token=abc123 failed' + ]); + + /** @Then what follows the question mark is gone and the path is intact in all of them */ + self::assertStringContainsString( + 'data={"uri":"/v1/clients?","route":"/v1/clients",' + . '"message":"GET https://api.example.com/v1/clients? failed"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenFilterComparesThenOperandIsMaskedAndOperatorSurvives(): void + { + /** @Given a structured logger masking filter operands */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) + ->build(); + + /** @When logging filters that compare, negate, and range over values */ + $logger->info(message: 'query.received', context: [ + 'equality' => 'status==active;document==12345678900', + 'negation' => 'status!=canceled', + 'range' => 'created_at=ge=2026-01-01,created_at=le=2026-02-01' + ]); + + /** @Then every operand is masked and every field and operator stays readable */ + self::assertStringContainsString( + 'data={"equality":"status==********;document==********","negation":"status!=********",' + . '"range":"created_at=ge=********,created_at=le=********"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenFilterComparesAgainstAListThenTheWholeListIsMasked(): void + { + /** @Given a structured logger masking filter operands */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) + ->build(); + + /** @When logging a filter whose operand is a parenthesized list */ + $logger->info(message: 'query.received', context: [ + 'filter' => 'document=in=(12345678900,98765432100);status=out=(canceled,failed)' + ]); + + /** @Then no value inside the parentheses survives, which is where a list leaks */ + self::assertStringContainsString( + 'data={"filter":"document=in=********;status=out=********"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenValueCarriesNoComparisonThenLeavesItUntouched(): void + { + /** @Given a structured logger masking filter operands */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'ledger') + ->withRedactions(FilterExpressionRedaction::default()) + ->build(); + + /** @When logging a value that carries no comparison at all */ + $logger->info(message: 'query.received', context: ['sort' => 'created_at desc']); + + /** @Then it travels unchanged, because there is no operand to mask */ + self::assertStringContainsString('data={"sort":"created_at desc"}', $this->logStream->contents()); + } + + public function testRedactWhenEmailPrefixIsGivenThenItOverridesTheDefaultWindow(): void + { + /** @Given a structured logger showing more of the local part than the default two characters */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 4)) + ->build(); + + /** @When logging an address */ + $logger->info(message: 'user.created', context: ['email' => 'maria.silva@example.com']); + + /** @Then four characters of the local part survive, and not the two the default would keep */ + self::assertStringContainsString('data={"email":"mari*******@example.com"}', $this->logStream->contents()); + } + + public function testRedactWhenQueryParametersAreAllowedThenOnlyTheOthersAreMasked(): void + { + /** @Given a structured logger keeping the query parameters this API answers by */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'ledger') + ->withRedactions(QueryParametersRedaction::keeping(fields: ['sort', 'page*'])) + ->build(); + + /** @When logging a request carrying an allowed parameter, a wildcard match, and one nobody declared */ + $logger->info(message: 'request.received', context: [ + 'uri' => '/v1/clients', + 'query_parameters' => ['sort' => 'created_at', 'page_size' => '20', 'document' => '12345678900'], + 'body' => ['document' => '12345678900'] + ]); + + /** @Then the undeclared parameter is masked, the allowed ones survive, and nothing outside the branch moves */ + self::assertStringContainsString( + 'data={"uri":"/v1/clients","query_parameters":{"sort":"created_at","page_size":"20",' + . '"document":"********"},"body":{"document":"12345678900"}}', + $this->logStream->contents() + ); + } + + public function testRedactWhenNoQueryParameterIsAllowedThenEveryOneOfThemIsMasked(): void + { + /** @Given a structured logger allowing no query parameter at all */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'identity') + ->withRedactions(QueryParametersRedaction::default()) + ->build(); + + /** @When logging a request carrying two of them */ + $logger->info(message: 'request.received', context: [ + 'query_parameters' => ['sort' => 'created_at', 'document' => '12345678900'] + ]); + + /** @Then both are masked, because a parameter is readable only when it is named */ + self::assertStringContainsString( + 'data={"query_parameters":{"sort":"********","document":"********"}}', + $this->logStream->contents() + ); + } + + public function testRedactWhenBirthDateTravelsUnderAnotherNameThenTheDefaultCoversIt(): void + { + /** @Given a structured logger with the default birth date redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(BirthDateRedaction::default()) + ->build(); + + /** @When logging the same date under each name it travels by, next to a field that only looks alike */ + $logger->info(message: 'payer.registered', context: [ + 'birth_date' => '1990-07-21', + 'birthdate' => '1990-07-21', + 'date_of_birth' => '1990-07-21', + 'birthplace' => 'São Paulo', + 'plan' => 'saas' + ]); + + /** @Then the year and the separators survive in each of them, and the lookalike is untouched */ + self::assertStringContainsString( + 'data={"birth_date":"1990-**-**","birthdate":"1990-**-**","date_of_birth":"1990-**-**",' + . '"birthplace":"São Paulo","plan":"saas"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenBirthDateWindowIsGivenThenItOverridesTheDefault(): void + { + /** @Given a structured logger hiding the year as well */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(BirthDateRedaction::from(fields: ['birth_date'], visiblePrefixLength: 0)) + ->build(); + + /** @When logging a payer carrying a date of birth */ + $logger->info(message: 'payer.registered', context: ['birth_date' => '1990-07-21']); + + /** @Then nothing of the date survives but its shape, and not the year the default would keep */ + self::assertStringContainsString('data={"birth_date":"****-**-**"}', $this->logStream->contents()); + } + + public function testRedactWhenPostalCodeThenKeepsTheLeadingCharactersOfTheRegion(): void + { + /** @Given a structured logger with postal code redaction over three shapes of code */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code', 'zip', 'outward'])) + ->build(); + + /** @When logging an address carrying each of them */ + $logger->info(message: 'address.given', context: [ + 'postal_code' => '01310-100', + 'zip' => '94103', + 'outward' => 'SW1A 1AA' + ]); + + /** @Then each keeps the leading characters that name the region, with the separators preserved */ + self::assertStringContainsString( + 'data={"postal_code":"013**-***","zip":"941**","outward":"SW1* ***"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenPostalCodeTravelsUnderAnotherNameThenTheDefaultStillCoversIt(): void + { + /** @Given a structured logger with the default postal code redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::default()) + ->build(); + + /** @When logging the same value under each name it travels by, next to fields that only look alike */ + $logger->info(message: 'address.given', context: [ + 'postal_code' => '01310-100', + 'postcode' => '01310-100', + 'zip_code' => '01310-100', + 'zip' => 'invoices.zip', + 'area_code' => '11', + 'city' => 'São Paulo' + ]); + + /** @Then every name of the postal code is masked past the region, and the lookalikes are not */ + self::assertStringContainsString( + 'data={"postal_code":"013**-***","postcode":"013**-***","zip_code":"013**-***",' + . '"zip":"invoices.zip","area_code":"11","city":"São Paulo"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenPostalCodeWindowIsGivenThenItOverridesTheDefault(): void + { + /** @Given a structured logger keeping more of the code than the default three characters */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(PostalCodeRedaction::from(fields: ['postal_code'], visiblePrefixLength: 5)) + ->build(); + + /** @When logging an address */ + $logger->info(message: 'address.given', context: ['postal_code' => '01310-100']); + + /** @Then five characters survive, and not the three the default would keep */ + self::assertStringContainsString('data={"postal_code":"01310-***"}', $this->logStream->contents()); + } + + public function testRedactWhenFieldIsSpelledInAnotherCaseThenTheSameRuleCoversIt(): void + { + /** @Given a structured logger with the strategies that name their fields in snake case */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(SecretRedaction::default(), PostalCodeRedaction::default(), BirthDateRedaction::default()) + ->build(); + + /** @When logging a payload that spells the same names in camel case and with an acronym */ + $logger->info(message: 'payer.registered', context: [ + 'postalCode' => '01310-100', + 'birthDate' => '1990-07-21', + 'accessToken' => 'at-1', + 'debitCardToken' => 'tok-1', + 'APIKey' => 'ak-1', + 'plan' => 'saas' + ]); + + /** @Then every spelling of a covered name is masked, and the field nobody named survives */ + self::assertStringContainsString( + 'data={"postalCode":"013**-***","birthDate":"1990-**-**","accessToken":"********",' + . '"debitCardToken":"********","APIKey":"********","plan":"saas"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenPatternRequiresASeparatorThenTheBareFieldStaysOutOfIt(): void + { + /** @Given a structured logger covering only field names that carry a qualifier */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'user-service') + ->withRedactions(NameRedaction::from(fields: ['*_name'])) + ->build(); + + /** @When logging the qualified name in two spellings, next to the bare one */ + $logger->info(message: 'user.created', context: [ + 'client_name' => 'Maria Silva', + 'clientName' => 'Maria Silva', + 'name' => 'Maria Silva' + ]); + + /** @Then both spellings of the qualified name are masked and the bare one is not */ + self::assertStringContainsString( + 'data={"client_name":"Ma*********","clientName":"Ma*********","name":"Maria Silva"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenSecretFieldsFollowTheNamingConventionThenEachIsMasked(): void + { + /** @Given a structured logger with the default secret redaction */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'identity') + ->withRedactions(SecretRedaction::default()) + ->build(); + + /** @When logging fields that carry credentials under the conventional names */ + $logger->info(message: 'session.started', context: [ + 'access_token' => 'eyJhbGciOi', + 'client_secret' => 'super-secret', + 'authorization' => 'Bearer abc123', + 'credentials' => 'user:password', + 'private_key' => '-----BEGIN', + 'api_key' => 'ak_live_1', + 'role' => 'owner' + ]); + + /** @Then each of them is masked to the same width and the field that is not a secret survives */ + self::assertStringContainsString( + 'data={"access_token":"********","client_secret":"********","authorization":"********",' + . '"credentials":"********","private_key":"********","api_key":"********","role":"owner"}', + $this->logStream->contents() + ); + } + + public function testRedactWhenAllowListAndNullValueThenLeavesItUntouched(): void + { + /** @Given a structured logger allowing only the city field */ + $logger = StreamLogger::builder() + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment-service') + ->withRedactions(GenericRedaction::keeping(fields: ['city'], mask: Mask::fixed(length: 3))) + ->build(); + + /** @When logging with an absent value and a nested allowed field */ + $logger->info(message: 'address.saved', context: [ + 'city' => 'São Paulo', + 'street' => null, + 'nested' => ['city' => 'Rio', 'label' => 'x'] + ]); + + /** @Then the absent value is written as is and the nested allow list still applies */ + $output = $this->logStream->contents(); + + self::assertStringContainsString('"city":"São Paulo","street":null', $output); + self::assertStringContainsString('"nested":{"city":"Rio","label":"***"}', $output); + } +} diff --git a/tests/StructuredLoggerTest.php b/tests/Unit/StreamLoggerTest.php similarity index 91% rename from tests/StructuredLoggerTest.php rename to tests/Unit/StreamLoggerTest.php index 33aac77..c175e37 100644 --- a/tests/StructuredLoggerTest.php +++ b/tests/Unit/StreamLoggerTest.php @@ -2,21 +2,23 @@ declare(strict_types=1); -namespace Test\TinyBlocks\Logger; +namespace Test\TinyBlocks\Logger\Unit; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use TinyBlocks\Logger\Correlation; use TinyBlocks\Logger\Exceptions\UnknownLogLevel; -use TinyBlocks\Logger\LogContext; use TinyBlocks\Logger\Logger; use TinyBlocks\Logger\Redactions\DocumentRedaction; use TinyBlocks\Logger\Redactions\EmailRedaction; +use TinyBlocks\Logger\Redactions\GenericRedaction; +use TinyBlocks\Logger\Redactions\Mask; use TinyBlocks\Logger\Redactions\NameRedaction; -use TinyBlocks\Logger\Redactions\PasswordRedaction; use TinyBlocks\Logger\Redactions\PhoneRedaction; -use TinyBlocks\Logger\StructuredLogger; +use TinyBlocks\Logger\Redactions\SecretRedaction; +use TinyBlocks\Logger\StreamLogger; -final class StructuredLoggerTest extends TestCase +final class StreamLoggerTest extends TestCase { private InMemoryStream $logStream; @@ -33,7 +35,7 @@ protected function tearDown(): void public function testLogWhenDebugEntryThenWritesDebugLevel(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'debug-service') ->build(); @@ -51,7 +53,7 @@ public function testLogWhenDebugEntryThenWritesDebugLevel(): void public function testLogWhenErrorEntryThenWritesErrorLevel(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'payment-service') ->build(); @@ -69,10 +71,10 @@ public function testLogWhenErrorEntryThenWritesErrorLevel(): void public function testRedactWhenShortPasswordThenMasksFully(): void { /** @Given a structured logger with password redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withRedactions(SecretRedaction::default()) ->build(); /** @When logging with a short password */ @@ -88,7 +90,7 @@ public function testRedactWhenShortPasswordThenMasksFully(): void public function testLogWhenInfoEntryThenWritesLevelAndData(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'account-service') ->build(); @@ -108,7 +110,7 @@ public function testLogWhenInfoEntryThenWritesLevelAndData(): void public function testLogWhenNoContextThenCorrelationIdIsEmpty(): void { /** @Given a structured logger without any context */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'no-context-service') ->build(); @@ -123,7 +125,7 @@ public function testLogWhenNoContextThenCorrelationIdIsEmpty(): void public function testLogWhenNoPayloadThenWritesEmptyDataArray(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'minimal-service') ->build(); @@ -138,7 +140,7 @@ public function testLogWhenNoPayloadThenWritesEmptyDataArray(): void public function testRedactWhenNameFieldThenMasksAllButPrefix(): void { /** @Given a structured logger with name redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(NameRedaction::default()) @@ -158,7 +160,7 @@ public function testRedactWhenNameFieldThenMasksAllButPrefix(): void public function testLogWhenWarningEntryThenWritesWarningLevel(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'inventory-service') ->build(); @@ -176,7 +178,7 @@ public function testLogWhenWarningEntryThenWritesWarningLevel(): void public function testRedactWhenMultipleNameFieldsThenMasksEach(): void { /** @Given a structured logger with name redaction targeting multiple field names */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions( @@ -208,7 +210,7 @@ public function testRedactWhenMultipleNameFieldsThenMasksEach(): void public function testRedactWhenPhoneFieldThenMasksAllButSuffix(): void { /** @Given a structured logger with phone redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'notification-service') ->withRedactions(PhoneRedaction::default()) @@ -227,7 +229,7 @@ public function testRedactWhenPhoneFieldThenMasksAllButSuffix(): void public function testRedactWhenSeveralRulesThenAppliesEachRule(): void { /** @Given a structured logger with multiple redactions */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions( @@ -248,7 +250,7 @@ public function testRedactWhenSeveralRulesThenAppliesEachRule(): void /** @Then each field should be redacted according to its rule */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); self::assertStringContainsString('jo**@example.com', $output); self::assertStringContainsString('**********7766', $output); self::assertStringContainsString('John', $output); @@ -257,7 +259,7 @@ public function testRedactWhenSeveralRulesThenAppliesEachRule(): void public function testLogWhenNoRedactionThenLeavesDataUnmodified(): void { /** @Given a structured logger without any redactions */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'simple-service') ->build(); @@ -272,7 +274,7 @@ public function testLogWhenNoRedactionThenLeavesDataUnmodified(): void public function testRedactWhenMultipleEmailFieldsThenMasksEach(): void { /** @Given a structured logger with email redaction targeting multiple field names */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'notification-service') ->withRedactions( @@ -301,7 +303,7 @@ public function testRedactWhenMultipleEmailFieldsThenMasksEach(): void public function testRedactWhenMultiplePhoneFieldsThenMasksEach(): void { /** @Given a structured logger with phone redaction targeting multiple field names */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'contact-service') ->withRedactions( @@ -330,7 +332,7 @@ public function testRedactWhenMultiplePhoneFieldsThenMasksEach(): void public function testLogWhenCustomTemplateThenFollowsCustomFormat(): void { /** @Given a structured logger with a custom template */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") ->withComponent(component: 'custom-service') @@ -351,7 +353,7 @@ public function testLogWhenCustomTemplateThenFollowsCustomFormat(): void public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void { /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'payment-service') ->withRedactions(DocumentRedaction::default()) @@ -363,7 +365,7 @@ public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void /** @Then the document should be redacted showing only the last 3 characters */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); self::assertStringNotContainsString('12345678900', $output); self::assertStringContainsString('100.5', $output); } @@ -371,7 +373,7 @@ public function testRedactWhenDocumentFieldThenMasksAllButSuffix(): void public function testRedactWhenEmailFieldThenMasksLocalPartSuffix(): void { /** @Given a structured logger with email redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::default()) @@ -390,7 +392,7 @@ public function testRedactWhenEmailFieldThenMasksLocalPartSuffix(): void public function testRedactWhenNestedNameThenMasksAndKeepsSibling(): void { /** @Given a structured logger with name redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(NameRedaction::default()) @@ -417,7 +419,7 @@ public function testRedactWhenNestedNameThenMasksAndKeepsSibling(): void public function testRedactWhenMultipleDocumentFieldsThenMasksEach(): void { /** @Given a structured logger with document redaction targeting multiple field names */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::from(fields: ['cpf', 'cnpj'], visibleSuffixLength: 5)) @@ -441,10 +443,10 @@ public function testRedactWhenMultipleDocumentFieldsThenMasksEach(): void public function testRedactWhenMultiplePasswordFieldsThenMasksEach(): void { /** @Given a structured logger with password redaction targeting multiple field names */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::from(fields: ['password', 'secret', 'token'])) + ->withRedactions(SecretRedaction::from(fields: ['password', 'secret', 'token'])) ->build(); /** @When logging with multiple password field variations */ @@ -465,7 +467,7 @@ public function testRedactWhenMultiplePasswordFieldsThenMasksEach(): void public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void { /** @Given a structured logger built with redactions added in separate calls */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'multi-call-service') ->withRedactions(DocumentRedaction::default()) @@ -481,7 +483,7 @@ public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void /** @Then both fields should be redacted */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); self::assertStringContainsString('jo**@example.com', $output); self::assertStringNotContainsString('12345678900', $output); self::assertStringNotContainsString('john@example.com', $output); @@ -490,7 +492,7 @@ public function testBuildWhenRedactionsAddedSeparatelyThenAllApply(): void public function testLogWhenDefaultTemplateThenFollowsDefaultFormat(): void { /** @Given a structured logger using the default template */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'default-service') ->build(); @@ -510,7 +512,7 @@ public function testLogWhenDefaultTemplateThenFollowsDefaultFormat(): void public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'level-service') ->build(); @@ -525,14 +527,14 @@ public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void { /** @Given a structured logger with all available redactions */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'full-service') ->withRedactions( DocumentRedaction::default(), EmailRedaction::default(), PhoneRedaction::default(), - PasswordRedaction::default(), + SecretRedaction::default(), NameRedaction::default() ) ->build(); @@ -550,7 +552,7 @@ public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void /** @Then each field should be redacted according to its rule */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); self::assertStringContainsString('jo**@example.com', $output); self::assertStringContainsString('**********7766', $output); self::assertStringNotContainsString('s3cr3t!', $output); @@ -562,7 +564,7 @@ public function testRedactWhenAllRulesThenMasksEverySensitiveField(): void public function testLogWhenKeyHasNewlineThenEscapesControlCharacter(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'injection-test') ->build(); @@ -577,10 +579,10 @@ public function testLogWhenKeyHasNewlineThenEscapesControlCharacter(): void public function testRedactWhenPasswordFieldThenMasksAndKeepsSibling(): void { /** @Given a structured logger with password redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withRedactions(SecretRedaction::default()) ->build(); /** @When logging with a password field */ @@ -597,10 +599,10 @@ public function testRedactWhenPasswordFieldThenMasksAndKeepsSibling(): void public function testRedactWhenNestedPasswordThenMasksAndKeepsSibling(): void { /** @Given a structured logger with password redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withRedactions(SecretRedaction::default()) ->build(); /** @When logging with a nested structure containing a password field */ @@ -621,7 +623,7 @@ public function testRedactWhenNestedPasswordThenMasksAndKeepsSibling(): void public function testBuildWhenNoStreamConfiguredThenUsesStderrFallback(): void { /** @Given a logger builder without an explicit stream */ - $builder = StructuredLogger::create()->withComponent(component: 'stderr-service'); + $builder = StreamLogger::builder()->withComponent(component: 'stderr-service'); /** @When building the logger so the stream falls back to standard error */ $logger = $builder->build(); @@ -633,7 +635,7 @@ public function testBuildWhenNoStreamConfiguredThenUsesStderrFallback(): void public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void { /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'multi-level-service') ->withRedactions(DocumentRedaction::default()) @@ -648,8 +650,8 @@ public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void /** @Then both the nested and scalar documents should be redacted */ $output = $this->logStream->contents(); - self::assertStringContainsString('********100', $output); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); + self::assertStringContainsString('*********00', $output); self::assertStringNotContainsString('11111111100', $output); self::assertStringNotContainsString('99999999900', $output); } @@ -657,10 +659,10 @@ public function testRedactWhenNestedAndScalarAtSameLevelThenMasksBoth(): void public function testRedactWhenCustomMaskLengthThenMaskMatchesThatWidth(): void { /** @Given a structured logger with password redaction configured with a custom fixed mask length */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::from(fields: ['password'], fixedMaskLength: 12)) + ->withRedactions(SecretRedaction::from(fields: ['password'], fixedMaskLength: 12)) ->build(); /** @When logging with a password field */ @@ -676,13 +678,13 @@ public function testRedactWhenCustomMaskLengthThenMaskMatchesThatWidth(): void public function testLogWhenContextDerivedThenOriginalOmitsCorrelationId(): void { /** @Given a structured logger */ - $original = StructuredLogger::create() + $original = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') ->build(); /** @And a contextual logger derived from it */ - $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999')); + $correlated = $original->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-999')); /** @When logging from the original instance */ $original->info(message: 'auth.check'); @@ -691,13 +693,13 @@ public function testLogWhenContextDerivedThenOriginalOmitsCorrelationId(): void self::assertStringNotContainsString('correlation_id=ctx-999', $this->logStream->contents()); /** @And deriving a context yields a new instance, leaving the original untouched */ - self::assertNotSame($original, $contextual); + self::assertNotSame($original, $correlated); } public function testRedactWhenEmailHasNoAtSignThenMasksByCharacterCount(): void { /** @Given a structured logger with email redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::default()) @@ -716,7 +718,7 @@ public function testRedactWhenEmailHasNoAtSignThenMasksByCharacterCount(): void public function testRedactWhenNameIsMultibyteThenPrefixCountsCharacters(): void { /** @Given a structured logger with name redaction and a multibyte value */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'profile-service') ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 2)) @@ -732,9 +734,9 @@ public function testRedactWhenNameIsMultibyteThenPrefixCountsCharacters(): void public function testLogWhenContextBoundAtCreationThenWritesCorrelationId(): void { /** @Given a structured logger created with a correlation context */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) - ->withContext(context: LogContext::from(correlationId: 'req-initial')) + ->withCorrelation(correlation: Correlation::from(correlationId: 'req-initial')) ->withComponent(component: 'order-service') ->build(); @@ -748,7 +750,7 @@ public function testLogWhenContextBoundAtCreationThenWritesCorrelationId(): void public function testLogWhenPayloadIsUnencodableThenWritesEncodingFailure(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'broken-json-service') ->build(); @@ -763,7 +765,7 @@ public function testLogWhenPayloadIsUnencodableThenWritesEncodingFailure(): void public function testRedactWhenEmailStartsWithAtSignThenLeavesValueIntact(): void { /** @Given a structured logger with email redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) @@ -779,7 +781,7 @@ public function testRedactWhenEmailStartsWithAtSignThenLeavesValueIntact(): void public function testRedactWhenNestedArrayThenMasksTargetAndKeepsSiblings(): void { /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'nested-service') ->withRedactions(DocumentRedaction::default()) @@ -798,7 +800,7 @@ public function testRedactWhenNestedArrayThenMasksTargetAndKeepsSiblings(): void /** @Then the document should be redacted within the nested structure */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $output); self::assertStringNotContainsString('12345678900', $output); /** @And all sibling fields in the nested array must be preserved */ @@ -812,10 +814,10 @@ public function testRedactWhenPasswordVariesInLengthThenMaskIsFixedWidth(string { /** @Given a password whose length varies across runs */ /** @And a structured logger with password redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') - ->withRedactions(PasswordRedaction::default()) + ->withRedactions(SecretRedaction::default()) ->build(); /** @When logging with the provided password */ @@ -828,7 +830,7 @@ public function testRedactWhenPasswordVariesInLengthThenMaskIsFixedWidth(string public function testRedactWhenPhoneIsMultibyteThenSuffixCountsCharacters(): void { /** @Given a structured logger with phone redaction and a multibyte suffix */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'contact-service') ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 3)) @@ -844,7 +846,7 @@ public function testRedactWhenPhoneIsMultibyteThenSuffixCountsCharacters(): void public function testRedactWhenDeeplyNestedThenMasksTargetsAndKeepsSibling(): void { /** @Given a structured logger with document redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'deep-service') ->withRedactions(DocumentRedaction::default()) @@ -864,21 +866,21 @@ public function testRedactWhenDeeplyNestedThenMasksTargetsAndKeepsSibling(): voi /** @Then the deeply nested document should be redacted */ $output = $this->logStream->contents(); - self::assertStringContainsString('********900', $output); + self::assertStringContainsString('*********00', $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::assertStringContainsString('*********00', $output); self::assertStringNotContainsString('99988877700', $output); } public function testRedactWhenNameShorterThanVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with name redaction configured to show 10 characters */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 10)) @@ -897,17 +899,17 @@ public function testRedactWhenNameShorterThanVisibleThenLeavesValueIntact(): voi public function testWithContextWhenCustomTemplateThenTemplateStillApplies(): void { /** @Given a structured logger with a custom template */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withTemplate(template: "[%s] %s | %s | %s | %s | %s\n") ->withComponent(component: 'template-service') ->build(); /** @And a contextual logger derived from it */ - $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-tmpl')); + $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-tmpl')); /** @When logging through the contextual logger */ - $contextual->info(message: 'template.event'); + $correlated->info(message: 'template.event'); /** @Then the custom template should be preserved */ $output = $this->logStream->contents(); @@ -919,16 +921,16 @@ public function testWithContextWhenCustomTemplateThenTemplateStillApplies(): voi public function testLogWhenContextDerivedThenContextualWritesCorrelationId(): void { /** @Given a structured logger */ - $original = StructuredLogger::create() + $original = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'auth-service') ->build(); /** @And a contextual logger derived from it */ - $contextual = $original->withContext(context: LogContext::from(correlationId: 'ctx-999')); + $correlated = $original->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-999')); /** @When logging from the contextual instance */ - $contextual->info(message: 'auth.success'); + $correlated->info(message: 'auth.success'); /** @Then the contextual line carries the correlation ID */ self::assertStringContainsString('correlation_id=ctx-999', $this->logStream->contents()); @@ -937,7 +939,7 @@ public function testLogWhenContextDerivedThenContextualWritesCorrelationId(): vo public function testLogWhenDataHasSlashesAndUnicodeThenLeavesThemUnescaped(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'encoding-service') ->build(); @@ -962,7 +964,7 @@ public function testLogWhenDataHasSlashesAndUnicodeThenLeavesThemUnescaped(): vo public function testRedactWhenNameLengthEqualsVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with name redaction where visible length equals value length */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(NameRedaction::from(fields: ['name'], visiblePrefixLength: 3)) @@ -981,7 +983,7 @@ public function testRedactWhenNameLengthEqualsVisibleThenLeavesValueIntact(): vo public function testRedactWhenPhoneShorterThanVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with phone redaction configured to show 10 characters */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'notification-service') ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 10)) @@ -1000,16 +1002,16 @@ public function testRedactWhenPhoneShorterThanVisibleThenLeavesValueIntact(): vo public function testLogWhenContextBoundAfterCreationThenWritesCorrelationId(): void { /** @Given a structured logger */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'order-service') ->build(); /** @And a contextual logger derived after creation */ - $loggerWithContext = $logger->withContext(context: LogContext::from(correlationId: 'req-abc-123')); + $correlatedLogger = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')); /** @When logging from the contextual logger */ - $loggerWithContext->info(message: 'order.placed', context: ['orderId' => 42]); + $correlatedLogger->info(message: 'order.placed', context: ['orderId' => 42]); /** @Then the output should contain the correlation ID */ self::assertStringContainsString('correlation_id=req-abc-123', $this->logStream->contents()); @@ -1018,7 +1020,7 @@ public function testLogWhenContextBoundAfterCreationThenWritesCorrelationId(): v public function testRedactWhenDocumentIsMultibyteThenSuffixCountsCharacters(): void { /** @Given a structured logger with document redaction and a multibyte suffix */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3)) @@ -1034,7 +1036,7 @@ public function testRedactWhenDocumentIsMultibyteThenSuffixCountsCharacters(): v public function testRedactWhenPhoneLengthEqualsVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with phone redaction where visible length equals value length */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'notification-service') ->withRedactions(PhoneRedaction::from(fields: ['phone'], visibleSuffixLength: 4)) @@ -1053,7 +1055,7 @@ public function testRedactWhenPhoneLengthEqualsVisibleThenLeavesValueIntact(): v public function testRedactWhenDocumentShorterThanVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with document redaction configured to show 10 characters */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 10)) @@ -1072,7 +1074,7 @@ public function testRedactWhenDocumentShorterThanVisibleThenLeavesValueIntact(): public function testRedactWhenDocumentLengthEqualsVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with document redaction where visible length equals value length */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'kyc-service') ->withRedactions(DocumentRedaction::from(fields: ['document'], visibleSuffixLength: 3)) @@ -1091,7 +1093,7 @@ public function testRedactWhenDocumentLengthEqualsVisibleThenLeavesValueIntact() public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies(): void { /** @Given a structured logger with a redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'secure-service') ->withRedactions( @@ -1103,10 +1105,10 @@ public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies( ->build(); /** @And a contextual logger derived from it */ - $contextual = $logger->withContext(context: LogContext::from(correlationId: 'ctx-preserve')); + $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'ctx-preserve')); /** @When logging through the contextual logger */ - $contextual->error(message: 'secure.action', context: ['secret' => 'my-secret-value']); + $correlated->error(message: 'secure.action', context: ['secret' => 'my-secret-value']); /** @Then the redaction should still be applied */ $output = $this->logStream->contents(); @@ -1118,7 +1120,7 @@ public function testWithContextWhenRedactionConfiguredThenRedactionStillApplies( public function testRedactWhenScalarsFollowNestedArrayThenMasksAllAndKeepsStatus(): void { /** @Given a structured logger with redaction for two fields */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'batch-service') ->withRedactions(DocumentRedaction::from(fields: ['document', 'taxId'], visibleSuffixLength: 3)) @@ -1146,12 +1148,12 @@ public function testRedactWhenScalarsFollowNestedArrayThenMasksAllAndKeepsStatus public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveFields(): void { - /** @Given a StructuredLogger configured with default redactions for password, email, document, phone, and name */ - $logger = StructuredLogger::create() + /** @Given a StreamLogger configured with redactions for password, email, document, phone, and name */ + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'test') ->withRedactions( - PasswordRedaction::default(), + GenericRedaction::masking(mask: Mask::fixed(), fields: ['password']), EmailRedaction::default(), DocumentRedaction::default(), PhoneRedaction::default(), @@ -1216,7 +1218,7 @@ public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveField self::assertStringContainsString('@example.com', $decoded['customer']['email']); self::assertStringNotContainsString('maria.silva@example.com', $decoded['customer']['email']); - self::assertStringEndsWith('900', $decoded['customer']['document']); + self::assertStringEndsWith('00', $decoded['customer']['document']); self::assertStringContainsString('*', $decoded['customer']['document']); self::assertStringNotContainsString('12345678900', $decoded['customer']['document']); @@ -1277,7 +1279,7 @@ public function testRedactWhenDeeplyNestedAcrossLevelsThenMasksAllSensitiveField public function testRedactWhenEmailLocalPartIsMultibyteThenPrefixCountsCharacters(): void { /** @Given a structured logger with email redaction */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 2)) @@ -1293,7 +1295,7 @@ public function testRedactWhenEmailLocalPartIsMultibyteThenPrefixCountsCharacter public function testRedactWhenEmailLocalPartShorterThanVisibleThenLeavesValueIntact(): void { /** @Given a structured logger with email redaction configured to show 10 characters */ - $logger = StructuredLogger::create() + $logger = StreamLogger::builder() ->withStream(stream: $this->logStream->handle()) ->withComponent(component: 'user-service') ->withRedactions(EmailRedaction::from(fields: ['email'], visiblePrefixLength: 10)) diff --git a/tests/Unit/TelemetryLoggerTest.php b/tests/Unit/TelemetryLoggerTest.php new file mode 100644 index 0000000..22874ac --- /dev/null +++ b/tests/Unit/TelemetryLoggerTest.php @@ -0,0 +1,382 @@ +logStream = InMemoryStream::create(); + } + + protected function tearDown(): void + { + $this->logStream->close(); + } + + public function testMetricWhenWrittenThenTheLineIsTheRecordAlone(): void + { + /** @Given a telemetry logger writing to a stream */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @When writing a measurement */ + $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule')); + + /** @Then the line is a JSON object and nothing else, which is what the agent parses */ + self::assertSame( + 1, + json_decode(trim($this->logStream->contents()), true)['AppointmentNoShow'] + ); + } + + public function testLogWhenLevelIsUnknownThenThrowsUnknownLogLevel(): void + { + /** @Given a telemetry logger */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->build(); + + /** @Then logging at an unsupported level raises an unknown log level error */ + $this->expectException(UnknownLogLevel::class); + + /** @When logging at a level outside the supported set */ + $logger->log('not-a-level', 'some.key'); + } + + public function testMetricWhenComponentGivenThenTheLineCarriesItAsAField(): void + { + /** @Given a telemetry logger that knows which component it speaks for */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment') + ->build(); + + /** @When writing a measurement */ + $logger->metric(metric: Metric::of(name: 'ChargeAccepted', namespace: 'Acme/Payment')); + + /** @Then the component travels on the metric, so a series can be traced to who emitted it */ + self::assertSame('payment', json_decode(trim($this->logStream->contents()), true)['component']); + } + + public function testBuilderWhenRedactionsGivenInTwoCallsThenBothReachTheMetric(): void + { + /** @Given a telemetry logger told about one redaction */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'identity') + ->withRedactions(SecretRedaction::default()) + ->withRedactions(GenericRedaction::removing(fields: ['stack_trace'])) + ->build(); + + /** @And a measurement covered by both */ + $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity') + ->withField(name: 'password', value: 'super-secret') + ->withField(name: 'stack_trace', value: '#0 /var/www/html/src/SignIn.php(7)'); + + /** @When writing it */ + $logger->metric(metric: $metric); + + /** @Then the later list was added to the earlier one, and not put in its place */ + $record = json_decode(trim($this->logStream->contents()), true); + + self::assertSame('********', $record['password']); + self::assertArrayNotHasKey('stack_trace', $record); + } + + public function testMetricWhenNotCorrelatedThenTheLineCarriesAnEmptyIdentifier(): void + { + /** @Given a telemetry logger with no correlation bound */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @When writing a measurement */ + $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule')); + + /** @Then the field is present and empty, so the shape of the record never varies */ + self::assertSame('', json_decode(trim($this->logStream->contents()), true)['correlation_id']); + } + + public function testWithCorrelationWhenDerivedThenTheOriginalLoggerStaysUnbound(): void + { + /** @Given a telemetry logger with no correlation bound */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @And a correlated logger derived from it */ + $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-derived')); + + /** @When writing a measurement through the original */ + $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule')); + + /** @Then the derived logger is another instance and the original was not bound by it */ + self::assertNotSame($logger, $correlated); + self::assertSame('', json_decode(trim($this->logStream->contents()), true)['correlation_id']); + } + + public function testMetricWhenTheLoggerIsCorrelatedThenTheLineCarriesTheIdentifier(): void + { + /** @Given a telemetry logger built around a correlation */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->withCorrelation(correlation: Correlation::from(correlationId: 'req-abc-123')) + ->build(); + + /** @When writing a measurement */ + $logger->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule')); + + /** @Then the identifier travels on the metric, which is what stitches it to the entries around it */ + self::assertSame('req-abc-123', json_decode(trim($this->logStream->contents()), true)['correlation_id']); + } + + public function testMetricAndEntryWhenBothWrittenThenNeitherSharesALineWithTheOther(): void + { + /** @Given a telemetry logger writing to a stream */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @And an entry already written through it */ + $logger->info(message: 'appointment.created', context: ['appointment_id' => 42]); + + /** @When writing a measurement after it */ + $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule')); + + /** @Then the entry keeps the log shape and the metric keeps the record shape, on two lines */ + $lines = explode(PHP_EOL, trim($this->logStream->contents())); + + self::assertCount(2, $lines); + self::assertStringContainsString('key=appointment.created', $lines[0]); + self::assertStringContainsString('data={"appointment_id":42}', $lines[0]); + self::assertSame(1, json_decode($lines[1], true)['AppointmentCreated']); + } + + public function testMetricWhenAFieldIsCoveredByARedactionThenTheLineCarriesItMasked(): void + { + /** @Given a telemetry logger that masks passwords */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'identity') + ->withRedactions(SecretRedaction::default()) + ->build(); + + /** @And a measurement carrying a field the redaction covers */ + $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity') + ->withField(name: 'password', value: 'super-secret'); + + /** @When writing it */ + $logger->metric(metric: $metric); + + /** @Then the metric line carries the masked value, as the log line would */ + self::assertSame('********', json_decode(trim($this->logStream->contents()), true)['password']); + } + + public function testBuilderWhenTemplateGivenThenTheEntryFollowsItAndTheMetricDoesNot(): void + { + /** @Given a telemetry logger built with a template of its own */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withTemplate(template: "%4\$s|%5\$s|%6\$s\n") + ->withComponent(component: 'schedule') + ->build(); + + /** @And an entry already written through it */ + $logger->warning(message: 'appointment.late', context: ['minutes' => 15]); + + /** @When writing a measurement after it */ + $logger->metric(metric: Metric::of(name: 'AppointmentLate', namespace: 'Acme/Schedule')); + + /** @Then the entry follows the template and the metric keeps the shape its format renders */ + $lines = explode(PHP_EOL, trim($this->logStream->contents())); + + self::assertSame('WARNING|appointment.late|{"minutes":15}', $lines[0]); + self::assertSame(1, json_decode($lines[1], true)['AppointmentLate']); + } + + public function testMetricWhenADimensionIsNotCoveredByARedactionThenTheLineCarriesIt(): void + { + /** @Given a telemetry logger that masks a field name */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'subscription') + ->withRedactions(GenericRedaction::masking(mask: Mask::proportional(), fields: ['document'])) + ->build(); + + /** @And a measurement broken down by a dimension no redaction covers */ + $metric = Metric::of(name: 'SubscriptionRenewed', namespace: 'Acme/Subscription') + ->withDimension(name: 'subscription_type', value: 'saas'); + + /** @When writing it */ + $logger->metric(metric: $metric); + + /** @Then nothing is refused, and the dimension is declared and repeated at the root */ + $record = json_decode(trim($this->logStream->contents()), true); + + self::assertSame('saas', $record['subscription_type']); + self::assertSame([['subscription_type']], EmbeddedMetricPayload::from(payload: $record)->dimensions()); + } + + public function testMetricWhenAFieldIsRemovedByARedactionThenItLeavesTheLineEntirely(): void + { + /** @Given a telemetry logger that drops a field instead of masking it */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'payment') + ->withRedactions(GenericRedaction::removing(fields: ['stack_trace'])) + ->build(); + + /** @And a measurement carrying the field that is dropped and one that is kept */ + $metric = Metric::of(name: 'ChargeFailed', namespace: 'Acme/Payment') + ->withField(name: 'stack_trace', value: '#0 /var/www/html/src/Charge.php(42)') + ->withField(name: 'gateway', value: 'acquirer-a'); + + /** @When writing it */ + $logger->metric(metric: $metric); + + /** @Then the dropped field is absent from the record and the other one survives */ + $record = json_decode(trim($this->logStream->contents()), true); + + self::assertArrayNotHasKey('stack_trace', $record); + self::assertSame('acquirer-a', $record['gateway']); + } + + public function testBuilderWhenDerivedThenTheEarlierBuilderStillBuildsWhatItDescribed(): void + { + /** @Given a builder describing a component */ + $builder = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule'); + + /** @And a copy of it describing another component */ + $derived = $builder->withComponent(component: 'payment'); + + /** @When building through the earlier builder */ + $builder->build()->metric(metric: Metric::of(name: 'AppointmentNoShow', namespace: 'Acme/Schedule')); + + /** @Then the copy is another builder and the earlier one was not changed by it */ + self::assertNotSame($builder, $derived); + self::assertSame('schedule', json_decode(trim($this->logStream->contents()), true)['component']); + } + + public function testMetricWhenMeasuredInAUnitThenTheLineDeclaresItAndKeepsThePrecision(): void + { + /** @Given a telemetry logger writing to a stream */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @And a measurement that is a duration and not a count */ + $metric = Metric::of(name: 'AppointmentDuration', namespace: 'Acme/Schedule') + ->withUnit(unit: MetricUnit::SECONDS) + ->withValue(value: 12.5); + + /** @When writing it */ + $logger->metric(metric: $metric); + + /** @Then the declaration carries the unit and the value keeps its precision */ + $record = json_decode(trim($this->logStream->contents()), true); + + self::assertSame( + [['Name' => 'AppointmentDuration', 'Unit' => 'Seconds']], + EmbeddedMetricPayload::from(payload: $record)->metrics() + ); + self::assertSame(12.5, $record['AppointmentDuration']); + } + + public function testMetricWhenEntriesAreSilencedByTheThresholdThenTheSeriesStillTravels(): void + { + /** @Given a telemetry logger quiet enough to discard an informational entry */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->withMinimumLevel(minimumLevel: LogLevel::ERROR) + ->build(); + + /** @And an entry below the threshold, which is discarded */ + $logger->info(message: 'appointment.created'); + + /** @When writing a measurement */ + $logger->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule')); + + /** @Then only the metric reached the stream, because a measurement carries no severity */ + $lines = explode(PHP_EOL, trim($this->logStream->contents())); + + self::assertCount(1, $lines); + self::assertSame(1, json_decode($lines[0], true)['AppointmentCreated']); + } + + public function testWithCorrelationWhenDerivedThenTheEntryAndTheMetricCarryTheIdentifier(): void + { + /** @Given a telemetry logger with no correlation bound */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'schedule') + ->build(); + + /** @And a correlated logger derived from it */ + $correlated = $logger->withCorrelation(correlation: Correlation::from(correlationId: 'req-derived')); + + /** @And an entry already written through the derived logger */ + $correlated->info(message: 'appointment.created'); + + /** @When writing a measurement through it */ + $correlated->metric(metric: Metric::of(name: 'AppointmentCreated', namespace: 'Acme/Schedule')); + + /** @Then both records carry the identifier the derived logger was bound to */ + $lines = explode(PHP_EOL, trim($this->logStream->contents())); + + self::assertStringContainsString('correlation_id=req-derived', $lines[0]); + self::assertSame('req-derived', json_decode($lines[1], true)['correlation_id']); + } + + public function testMetricWhenADimensionIsCoveredByARedactionThenRefusesAndNamesTheAlternative(): void + { + /** @Given a telemetry logger that masks a field name */ + $logger = TelemetryLogger::builder(format: EmbeddedMetricFormat::default()) + ->withStream(stream: $this->logStream->handle()) + ->withComponent(component: 'identity') + ->withRedactions(GenericRedaction::masking(mask: Mask::proportional(), fields: ['document'])) + ->build(); + + /** @And a measurement broken down by a dimension carrying that same name */ + $metric = Metric::of(name: 'SignInFailed', namespace: 'Acme/Identity') + ->withDimension(name: 'document', value: '12345678900'); + + /** @Then the refusal names the dimension and points at the field */ + $this->expectException(RedactedDimension::class); + $this->expectExceptionMessage( + 'The dimension is covered by a redaction. A dimension value reaches the metric index of ' + . 'the backend, where no redaction and no log retention can reach it. Emit it as a field instead.' + ); + + /** @When writing it */ + $logger->metric(metric: $metric); + } +}